diff --git a/template/Modules/Data/Tests/Sources/Mocks/UserDefaultsManagerMock.swift b/template/Modules/Data/Tests/Sources/Mocks/UserDefaultsManagerMock.swift new file mode 100644 index 00000000..a6a12eb4 --- /dev/null +++ b/template/Modules/Data/Tests/Sources/Mocks/UserDefaultsManagerMock.swift @@ -0,0 +1,82 @@ +import Foundation + +@testable import Data + +final class UserDefaultsManagerMock: UserDefaultsManagerProtocol, @unchecked Sendable { + + private var storage: [String: Any] = [:] + private var synchronizeCallCount = 0 + + var didCallSynchronize: Bool { synchronizeCallCount > 0 } + var synchronizeCallCountValue: Int { synchronizeCallCount } + + func set(_ value: Any?, for key: String) { + if let value = value { + storage[key] = value + } else { + storage.removeValue(forKey: key) + } + } + + func setObject(_ value: T?, key: String) { + if let value = value { + storage[key] = value + } else { + storage.removeValue(forKey: key) + } + } + + func getStringValue(for key: String) -> String? { + return storage[key] as? String + } + + func getBooleanValue(for key: String) -> Bool { + return storage[key] as? Bool ?? false + } + + func getIntValue(for key: String) -> Int { + return storage[key] as? Int ?? 0 + } + + func getArray(for key: String) -> [Any]? { + return storage[key] as? [Any] + } + + func getDataValue(for key: String) -> Data? { + return storage[key] as? Data + } + + func getObject(ofType: T.Type, key: String) -> T? { + return storage[key] as? T + } + + func getValue(for key: String) -> Any? { + return storage[key] + } + + func getAllKeys(withPrefix prefix: String) -> [String] { + return storage.keys.filter { $0.hasPrefix(prefix) } + } + + func clearData(forKeys keys: [String]) { + keys.forEach { storage.removeValue(forKey: $0) } + } + + func clearDataForCommonKeys() { + storage.removeAll() + } + + func synchronize() { + synchronizeCallCount += 1 + } + + // Test helper methods + func reset() { + storage.removeAll() + synchronizeCallCount = 0 + } + + func setStorageValue(_ value: Any?, forKey key: String) { + storage[key] = value + } +} diff --git a/template/Modules/Data/Tests/Sources/Repositories/RatingPromptStorageTests.swift b/template/Modules/Data/Tests/Sources/Repositories/RatingPromptStorageTests.swift new file mode 100644 index 00000000..9e50788d --- /dev/null +++ b/template/Modules/Data/Tests/Sources/Repositories/RatingPromptStorageTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing + +@testable import Data +import Domain + +@Suite("RatingPromptStorage") +struct RatingPromptStorageTests { + + @Test("getRatingPromptData returns data with default values when no data is stored") + func getRatingPromptDataReturnsDataWithDefaultValuesWhenNoDataIsStored() { + let userDefaultsManager = UserDefaultsManagerMock() + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + + let data = storage.getRatingPromptData() + + #expect(data.appLaunchCount == 0) + #expect(data.firstLaunchDate == nil) + #expect(data.lastPromptedVersion == nil) + #expect(data.significantEventCount == 0) + } + + @Test("getRatingPromptData returns stored values") + func getRatingPromptDataReturnsStoredValues() throws { + let userDefaultsManager = UserDefaultsManagerMock() + let testDate = Date() + let encodedDate = try JSONEncoder().encode(testDate) + + userDefaultsManager.setStorageValue(5, forKey: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + userDefaultsManager.setStorageValue(encodedDate, forKey: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) + userDefaultsManager.setStorageValue("1.2.0", forKey: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue) + userDefaultsManager.setStorageValue(3, forKey: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + let data = storage.getRatingPromptData() + + #expect(data.appLaunchCount == 5) + #expect(data.firstLaunchDate?.timeIntervalSince1970 == testDate.timeIntervalSince1970) + #expect(data.lastPromptedVersion == "1.2.0") + #expect(data.significantEventCount == 3) + } + + @Test("getRatingPromptData handles invalid date data gracefully") + func getRatingPromptDataHandlesInvalidDateDataGracefully() { + let userDefaultsManager = UserDefaultsManagerMock() + let invalidDateData = Data([0x00, 0x01, 0x02]) // Invalid JSON for Date + + userDefaultsManager.setStorageValue(invalidDateData, forKey: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) + + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + let data = storage.getRatingPromptData() + + #expect(data.firstLaunchDate == nil) + } + + @Test("recordAppLaunch increments launch count") + func recordAppLaunchIncrementsLaunchCount() { + let userDefaultsManager = UserDefaultsManagerMock() + userDefaultsManager.setStorageValue(5, forKey: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + storage.recordAppLaunch() + + let updatedCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + #expect(updatedCount == 6) + #expect(userDefaultsManager.didCallSynchronize) + } + + @Test("recordAppLaunch sets first launch date when not already set") + func recordAppLaunchSetsFirstLaunchDateWhenNotAlreadySet() throws { + let userDefaultsManager = UserDefaultsManagerMock() + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + + storage.recordAppLaunch() + + let dateData = userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) + #expect(dateData != nil) + + let decodedDate = try JSONDecoder().decode(Date.self, from: dateData!) + let now = Date() + // Allow for small time difference (within 1 second) + #expect(abs(decodedDate.timeIntervalSince(now)) < 1.0) + } + + @Test("recordAppLaunch does not overwrite existing first launch date") + func recordAppLaunchDoesNotOverwriteExistingFirstLaunchDate() throws { + let userDefaultsManager = UserDefaultsManagerMock() + let existingDate = Date().addingTimeInterval(-1000) + let encodedExistingDate = try JSONEncoder().encode(existingDate) + + userDefaultsManager.setStorageValue(encodedExistingDate, forKey: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) + + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + storage.recordAppLaunch() + + let dateData = userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue)! + let storedDate = try JSONDecoder().decode(Date.self, from: dateData) + + #expect(storedDate.timeIntervalSince1970 == existingDate.timeIntervalSince1970) + } + + @Test("recordSignificantEvent increments significant event count") + func recordSignificantEventIncrementsSignificantEventCount() { + let userDefaultsManager = UserDefaultsManagerMock() + userDefaultsManager.setStorageValue(3, forKey: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + storage.recordSignificantEvent() + + let updatedCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + #expect(updatedCount == 4) + #expect(userDefaultsManager.didCallSynchronize) + } + + @Test("recordPromptShown stores the app version") + func recordPromptShownStoresTheAppVersion() { + let userDefaultsManager = UserDefaultsManagerMock() + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + + storage.recordPromptShown(for: "2.1.0") + + let storedVersion = userDefaultsManager.getStringValue(for: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue) + #expect(storedVersion == "2.1.0") + #expect(userDefaultsManager.didCallSynchronize) + } + + @Test("resetCounters resets launch and event counters to zero") + func resetCountersResetsLaunchAndEventCountersToZero() { + let userDefaultsManager = UserDefaultsManagerMock() + userDefaultsManager.setStorageValue(10, forKey: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + userDefaultsManager.setStorageValue(5, forKey: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + storage.resetCounters() + + let launchCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + let eventCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + + #expect(launchCount == 0) + #expect(eventCount == 0) + #expect(userDefaultsManager.didCallSynchronize) + } + + @Test("clearAllData removes all rating prompt related data") + func clearAllDataRemovesAllRatingPromptRelatedData() throws { + let userDefaultsManager = UserDefaultsManagerMock() + let testDate = Date() + let encodedDate = try JSONEncoder().encode(testDate) + + // Set up initial data + userDefaultsManager.setStorageValue(10, forKey: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue) + userDefaultsManager.setStorageValue(encodedDate, forKey: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue) + userDefaultsManager.setStorageValue("1.0.0", forKey: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue) + userDefaultsManager.setStorageValue(5, forKey: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue) + + let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager) + storage.clearAllData() + + // Verify all data is cleared + let data = storage.getRatingPromptData() + #expect(data.appLaunchCount == 0) + #expect(data.firstLaunchDate == nil) + #expect(data.lastPromptedVersion == nil) + #expect(data.significantEventCount == 0) + #expect(userDefaultsManager.didCallSynchronize) + } +} diff --git a/template/Modules/Data/Tests/Sources/Services/DefaultRatingPromptPresenterTests.swift b/template/Modules/Data/Tests/Sources/Services/DefaultRatingPromptPresenterTests.swift new file mode 100644 index 00000000..447bf51b --- /dev/null +++ b/template/Modules/Data/Tests/Sources/Services/DefaultRatingPromptPresenterTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing + +@testable import Data +import Domain + +@Suite("DefaultRatingPromptPresenter") +struct DefaultRatingPromptPresenterTests { + + @Test("calls requestReview on StoreReviewController when show is called and returns result") + func callsRequestReviewOnStoreReviewControllerWhenShowIsCalledAndReturnsResult() async { + let storeReviewController = SpyStoreReviewController() + let presenter = DefaultRatingPromptPresenter(storeReviewController: storeReviewController) + + let result = await presenter.show() + + let callCount = await storeReviewController.requestReviewCallCount + #expect(callCount == 1) + #expect(result == true) + } + + @Test("returns false when store review controller returns false") + func returnsFalseWhenStoreReviewControllerReturnsFalse() async { + let storeReviewController = SpyStoreReviewController(shouldReturnTrue: false) + let presenter = DefaultRatingPromptPresenter(storeReviewController: storeReviewController) + + let result = await presenter.show() + + #expect(result == false) + } + + @Test("multiple calls to show result in multiple requestReview calls") + func multipleCallsToShowResultInMultipleRequestReviewCalls() async { + let storeReviewController = SpyStoreReviewController() + let presenter = DefaultRatingPromptPresenter(storeReviewController: storeReviewController) + + let result1 = await presenter.show() + let result2 = await presenter.show() + let result3 = await presenter.show() + + let callCount = await storeReviewController.requestReviewCallCount + #expect(callCount == 3) + #expect(result1 == true) + #expect(result2 == true) + #expect(result3 == true) + } +} + +// MARK: - Test Double + +@MainActor +private final class SpyStoreReviewController: StoreReviewControllerProtocol, @unchecked Sendable { + + private(set) var requestReviewCallCount = 0 + private let shouldReturnTrue: Bool + + init(shouldReturnTrue: Bool = true) { + self.shouldReturnTrue = shouldReturnTrue + } + + func requestReview() async -> Bool { + requestReviewCallCount += 1 + return shouldReturnTrue + } +} diff --git a/template/Modules/Domain/Tests/Sources/Entities/RatingPromptDataTests.swift b/template/Modules/Domain/Tests/Sources/Entities/RatingPromptDataTests.swift new file mode 100644 index 00000000..eab1eb00 --- /dev/null +++ b/template/Modules/Domain/Tests/Sources/Entities/RatingPromptDataTests.swift @@ -0,0 +1,93 @@ +import Foundation +import Testing + +@testable import Domain + +@Suite("RatingPromptData") +struct RatingPromptDataTests { + + @Test("initializes with default values") + func initializesWithDefaultValues() { + let data = RatingPromptData() + + #expect(data.appLaunchCount == 0) + #expect(data.firstLaunchDate == nil) + #expect(data.lastPromptedVersion == nil) + #expect(data.significantEventCount == 0) + } + + @Test("initializes with custom values") + func initializesWithCustomValues() { + let firstLaunchDate = Date() + let data = RatingPromptData( + appLaunchCount: 5, + firstLaunchDate: firstLaunchDate, + lastPromptedVersion: "1.2.0", + significantEventCount: 3 + ) + + #expect(data.appLaunchCount == 5) + #expect(data.firstLaunchDate == firstLaunchDate) + #expect(data.lastPromptedVersion == "1.2.0") + #expect(data.significantEventCount == 3) + } + + @Test("calculates days since first launch correctly") + func calculatesDaysSinceFirstLaunchCorrectly() { + let calendar = Calendar.current + let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: Date())! + + let data = RatingPromptData(firstLaunchDate: sevenDaysAgo) + + #expect(data.daysSinceFirstLaunch == 7) + } + + @Test("returns zero days when first launch date is nil") + func returnsZeroDaysWhenFirstLaunchDateIsNil() { + let data = RatingPromptData() + + #expect(data.daysSinceFirstLaunch == 0) + } + + @Test("returns false for hasBeenPromptedForCurrentVersion when lastPromptedVersion is nil") + func returnsFalseForHasBeenPromptedForCurrentVersionWhenLastPromptedVersionIsNil() { + let data = RatingPromptData() + + #expect(data.hasBeenPromptedForCurrentVersion("1.0.0") == false) + } + + @Test("returns true when current version matches lastPromptedVersion") + func returnsTrueWhenCurrentVersionMatchesLastPromptedVersion() { + let data = RatingPromptData(lastPromptedVersion: "1.2.0") + + #expect(data.hasBeenPromptedForCurrentVersion("1.2.0") == true) + } + + @Test("returns false when current version differs from lastPromptedVersion") + func returnsFalseWhenCurrentVersionDiffersFromLastPromptedVersion() { + let data = RatingPromptData(lastPromptedVersion: "1.2.0") + + #expect(data.hasBeenPromptedForCurrentVersion("1.3.0") == false) + } + + @Test("is codable") + func isCodable() throws { + let originalData = RatingPromptData( + appLaunchCount: 10, + firstLaunchDate: Date(), + lastPromptedVersion: "2.1.0", + significantEventCount: 5 + ) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encodedData = try encoder.encode(originalData) + let decodedData = try decoder.decode(RatingPromptData.self, from: encodedData) + + #expect(decodedData.appLaunchCount == originalData.appLaunchCount) + #expect(decodedData.firstLaunchDate?.timeIntervalSince1970 == originalData.firstLaunchDate?.timeIntervalSince1970) + #expect(decodedData.lastPromptedVersion == originalData.lastPromptedVersion) + #expect(decodedData.significantEventCount == originalData.significantEventCount) + } +} diff --git a/template/Modules/Domain/Tests/Sources/UseCases/RequestRatingPromptUseCaseTests.swift b/template/Modules/Domain/Tests/Sources/UseCases/RequestRatingPromptUseCaseTests.swift new file mode 100644 index 00000000..5d62f037 --- /dev/null +++ b/template/Modules/Domain/Tests/Sources/UseCases/RequestRatingPromptUseCaseTests.swift @@ -0,0 +1,230 @@ +import Foundation +import Testing + +@testable import Domain + +@Suite("RequestRatingPromptUseCase") +struct RequestRatingPromptUseCaseTests { + + @Test("returns false when should not show rating prompt") + func returnsFalseWhenShouldNotShowRatingPrompt() async { + let storage = SpyRatingPromptStorage() + let shouldShowUseCase = StubShouldShowRatingPromptUseCase(shouldShow: false) + let presenter = SpyRatingPromptPresenter() + let useCase = RequestRatingPromptUseCase( + storage: storage, + shouldShowRatingPromptUseCase: shouldShowUseCase, + presenter: presenter, + currentVersion: { "1.0.0" } + ) + let configuration = RatingPromptConfiguration() + + let result = await useCase(configuration: configuration) + + #expect(result == false) + #expect(presenter.showCallCount == 0) + #expect(storage.recordPromptShownCallCount == 0) + #expect(storage.resetCountersCallCount == 0) + } + + @Test("returns true and shows prompt when eligible") + func returnsTrueAndShowsPromptWhenEligible() async { + let storage = SpyRatingPromptStorage() + let shouldShowUseCase = StubShouldShowRatingPromptUseCase(shouldShow: true) + let presenter = SpyRatingPromptPresenter() + let useCase = RequestRatingPromptUseCase( + storage: storage, + shouldShowRatingPromptUseCase: shouldShowUseCase, + presenter: presenter, + currentVersion: { "1.0.0" } + ) + let configuration = RatingPromptConfiguration() + + let result = await useCase(configuration: configuration) + + #expect(result == true) + #expect(presenter.showCallCount == 1) + #expect(storage.recordPromptShownCallCount == 1) + #expect(storage.recordPromptShownVersion == "1.0.0") + } + + @Test("resets counters when resetCounterAfterPrompt is true") + func resetsCountersWhenResetCounterAfterPromptIsTrue() async { + let storage = SpyRatingPromptStorage() + let shouldShowUseCase = StubShouldShowRatingPromptUseCase(shouldShow: true) + let presenter = SpyRatingPromptPresenter() + let useCase = RequestRatingPromptUseCase( + storage: storage, + shouldShowRatingPromptUseCase: shouldShowUseCase, + presenter: presenter, + currentVersion: { "1.0.0" } + ) + let configuration = RatingPromptConfiguration(resetCounterAfterPrompt: true) + + let result = await useCase(configuration: configuration) + + #expect(result == true) + #expect(storage.resetCountersCallCount == 1) + } + + @Test("does not reset counters when resetCounterAfterPrompt is false") + func doesNotResetCountersWhenResetCounterAfterPromptIsFalse() async { + let storage = SpyRatingPromptStorage() + let shouldShowUseCase = StubShouldShowRatingPromptUseCase(shouldShow: true) + let presenter = SpyRatingPromptPresenter() + let useCase = RequestRatingPromptUseCase( + storage: storage, + shouldShowRatingPromptUseCase: shouldShowUseCase, + presenter: presenter, + currentVersion: { "1.0.0" } + ) + let configuration = RatingPromptConfiguration(resetCounterAfterPrompt: false) + + let result = await useCase(configuration: configuration) + + #expect(result == true) + #expect(storage.resetCountersCallCount == 0) + } + + @Test("uses current version from closure") + func usesCurrentVersionFromClosure() async { + let storage = SpyRatingPromptStorage() + let shouldShowUseCase = StubShouldShowRatingPromptUseCase(shouldShow: true) + let presenter = SpyRatingPromptPresenter() + let useCase = RequestRatingPromptUseCase( + storage: storage, + shouldShowRatingPromptUseCase: shouldShowUseCase, + presenter: presenter, + currentVersion: { "2.1.0" } + ) + let configuration = RatingPromptConfiguration() + + let result = await useCase(configuration: configuration) + + #expect(result == true) + #expect(storage.recordPromptShownVersion == "2.1.0") + } + + @Test("uses default current version when not provided") + func usesDefaultCurrentVersionWhenNotProvided() async { + let storage = SpyRatingPromptStorage() + let shouldShowUseCase = StubShouldShowRatingPromptUseCase(shouldShow: true) + let presenter = SpyRatingPromptPresenter() + let useCase = RequestRatingPromptUseCase( + storage: storage, + shouldShowRatingPromptUseCase: shouldShowUseCase, + presenter: presenter + ) + let configuration = RatingPromptConfiguration() + + let result = await useCase(configuration: configuration) + + #expect(result == true) + #expect(storage.recordPromptShownVersion != nil) + #expect(storage.recordPromptShownVersion?.isEmpty == false) + } + + @Test("returns false when presenter fails to show prompt") + func returnsFalseWhenPresenterFailsToShowPrompt() async { + let storage = SpyRatingPromptStorage() + let shouldShowUseCase = StubShouldShowRatingPromptUseCase(shouldShow: true) + let presenter = SpyRatingPromptPresenter(shouldReturnTrue: false) + let useCase = RequestRatingPromptUseCase( + storage: storage, + shouldShowRatingPromptUseCase: shouldShowUseCase, + presenter: presenter, + currentVersion: { "1.0.0" } + ) + let configuration = RatingPromptConfiguration() + + let result = await useCase(configuration: configuration) + + #expect(result == false) + #expect(presenter.showCallCount == 1) + #expect(storage.recordPromptShownCallCount == 0) // Should not record when presenter fails + #expect(storage.resetCountersCallCount == 0) // Should not reset when presenter fails + } + + @Test("passes configuration to shouldShowRatingPromptUseCase") + func passesConfigurationToShouldShowRatingPromptUseCase() async { + let storage = SpyRatingPromptStorage() + let shouldShowUseCase = StubShouldShowRatingPromptUseCase(shouldShow: true) + let presenter = SpyRatingPromptPresenter() + let useCase = RequestRatingPromptUseCase( + storage: storage, + shouldShowRatingPromptUseCase: shouldShowUseCase, + presenter: presenter, + currentVersion: { "1.0.0" } + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 14, + minimumAppLaunches: 20, + minimumSignificantEvents: 10 + ) + + let result = await useCase(configuration: configuration) + + #expect(result == true) + #expect(shouldShowUseCase.receivedConfiguration?.minimumDaysSinceFirstLaunch == 14) + #expect(shouldShowUseCase.receivedConfiguration?.minimumAppLaunches == 20) + #expect(shouldShowUseCase.receivedConfiguration?.minimumSignificantEvents == 10) + } +} + +// MARK: - Test Doubles + +private final class SpyRatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendable { + + private(set) var recordPromptShownCallCount = 0 + private(set) var recordPromptShownVersion: String? + private(set) var resetCountersCallCount = 0 + + func getRatingPromptData() -> RatingPromptData { + return RatingPromptData() + } + + func recordAppLaunch() {} + + func recordSignificantEvent() {} + + func recordPromptShown(for appVersion: String) { + recordPromptShownCallCount += 1 + recordPromptShownVersion = appVersion + } + + func resetCounters() { + resetCountersCallCount += 1 + } + + func clearAllData() {} +} + +private final class StubShouldShowRatingPromptUseCase: ShouldShowRatingPromptUseCaseProtocol, @unchecked Sendable { + + private let shouldShow: Bool + private(set) var receivedConfiguration: RatingPromptConfiguration? + + init(shouldShow: Bool) { + self.shouldShow = shouldShow + } + + func callAsFunction(configuration: RatingPromptConfiguration) -> Bool { + receivedConfiguration = configuration + return shouldShow + } +} + +private final class SpyRatingPromptPresenter: RatingPromptPresenterProtocol, @unchecked Sendable { + + private(set) var showCallCount = 0 + private let shouldReturnTrue: Bool + + init(shouldReturnTrue: Bool = true) { + self.shouldReturnTrue = shouldReturnTrue + } + + func show() async -> Bool { + showCallCount += 1 + return shouldReturnTrue + } +} diff --git a/template/Modules/Domain/Tests/Sources/UseCases/ShouldShowRatingPromptUseCaseTests.swift b/template/Modules/Domain/Tests/Sources/UseCases/ShouldShowRatingPromptUseCaseTests.swift new file mode 100644 index 00000000..2ab9d900 --- /dev/null +++ b/template/Modules/Domain/Tests/Sources/UseCases/ShouldShowRatingPromptUseCaseTests.swift @@ -0,0 +1,279 @@ +import Foundation +import Testing + +@testable import Domain + +@Suite("ShouldShowRatingPromptUseCase") +struct ShouldShowRatingPromptUseCaseTests { + + @Test("returns false when user has already been prompted for current version") + func returnsFalseWhenUserHasAlreadyBeenPromptedForCurrentVersion() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 20, + firstLaunchDate: Date().addingTimeInterval(-10 * 24 * 60 * 60), // 10 days ago + lastPromptedVersion: "1.0.0", + significantEventCount: 10 + ) + ) + let useCase = ShouldShowRatingPromptUseCase( + storage: storage, + currentVersion: "1.0.0" + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + #expect(result == false) + } + + @Test("returns true when user has been prompted for different version") + func returnsTrueWhenUserHasBeenPromptedForDifferentVersion() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 20, + firstLaunchDate: Date().addingTimeInterval(-10 * 24 * 60 * 60), // 10 days ago + lastPromptedVersion: "1.0.0", + significantEventCount: 10 + ) + ) + let useCase = ShouldShowRatingPromptUseCase( + storage: storage, + currentVersion: "1.1.0" + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + #expect(result == true) + } + + @Test("returns false when not enough days have passed since first launch") + func returnsFalseWhenNotEnoughDaysHavePassedSinceFirstLaunch() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 20, + firstLaunchDate: Date().addingTimeInterval(-5 * 24 * 60 * 60), // 5 days ago + lastPromptedVersion: nil, + significantEventCount: 10 + ) + ) + let useCase = ShouldShowRatingPromptUseCase( + storage: storage, + currentVersion: "1.0.0" + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + #expect(result == false) + } + + @Test("returns true when app launches insufficient but significant events sufficient") + func returnsTrueWhenAppLaunchesInsufficientButSignificantEventsSufficient() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 5, + firstLaunchDate: Date().addingTimeInterval(-10 * 24 * 60 * 60), // 10 days ago + lastPromptedVersion: nil, + significantEventCount: 10 + ) + ) + let useCase = ShouldShowRatingPromptUseCase( + storage: storage, + currentVersion: "1.0.0" + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + #expect(result == true) + } + + @Test("returns true when significant events insufficient but app launches sufficient") + func returnsTrueWhenSignificantEventsInsufficientButAppLaunchesSufficient() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 20, + firstLaunchDate: Date().addingTimeInterval(-10 * 24 * 60 * 60), // 10 days ago + lastPromptedVersion: nil, + significantEventCount: 2 + ) + ) + let useCase = ShouldShowRatingPromptUseCase( + storage: storage, + currentVersion: "1.0.0" + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + #expect(result == true) + } + + @Test("returns true when all criteria are met") + func returnsTrueWhenAllCriteriaAreMet() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 20, + firstLaunchDate: Date().addingTimeInterval(-10 * 24 * 60 * 60), // 10 days ago + lastPromptedVersion: nil, + significantEventCount: 10 + ) + ) + let useCase = ShouldShowRatingPromptUseCase( + storage: storage, + currentVersion: "1.0.0" + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + #expect(result == true) + } + + @Test("returns true when exactly meeting minimum requirements") + func returnsTrueWhenExactlyMeetingMinimumRequirements() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 10, + firstLaunchDate: Date().addingTimeInterval(-7 * 24 * 60 * 60), // 7 days ago + lastPromptedVersion: nil, + significantEventCount: 5 + ) + ) + let useCase = ShouldShowRatingPromptUseCase( + storage: storage, + currentVersion: "1.0.0" + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + #expect(result == true) + } + + @Test("uses default current version when not provided") + func usesDefaultCurrentVersionWhenNotProvided() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 20, + firstLaunchDate: Date().addingTimeInterval(-10 * 24 * 60 * 60), // 10 days ago + lastPromptedVersion: nil, + significantEventCount: 10 + ) + ) + let useCase = ShouldShowRatingPromptUseCase(storage: storage) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + #expect(result == true) + } + + @Test("returns false when both app launches and significant events are insufficient") + func returnsFalseWhenBothAppLaunchesAndSignificantEventsAreInsufficient() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 5, + firstLaunchDate: Date().addingTimeInterval(-10 * 24 * 60 * 60), // 10 days ago + lastPromptedVersion: nil, + significantEventCount: 2 + ) + ) + let useCase = ShouldShowRatingPromptUseCase( + storage: storage, + currentVersion: "1.0.0" + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + #expect(result == false) + } + + @Test("handles missing first launch date gracefully") + func handlesMissingFirstLaunchDateGracefully() { + let storage = StubRatingPromptStorage( + data: RatingPromptData( + appLaunchCount: 20, + firstLaunchDate: nil, + lastPromptedVersion: nil, + significantEventCount: 10 + ) + ) + let useCase = ShouldShowRatingPromptUseCase( + storage: storage, + currentVersion: "1.0.0" + ) + let configuration = RatingPromptConfiguration( + minimumDaysSinceFirstLaunch: 7, + minimumAppLaunches: 10, + minimumSignificantEvents: 5 + ) + + let result = useCase(configuration: configuration) + + // Should return false because daysSinceFirstLaunch returns 0 when firstLaunchDate is nil + #expect(result == false) + } +} + +private final class StubRatingPromptStorage: RatingPromptStorageProtocol, @unchecked Sendable { + + private let data: RatingPromptData + + init(data: RatingPromptData) { + self.data = data + } + + func getRatingPromptData() -> RatingPromptData { + return data + } + + func recordAppLaunch() {} + + func recordSignificantEvent() {} + + func recordPromptShown(for appVersion: String) {} + + func resetCounters() {} + + func clearAllData() {} +} diff --git a/template/Tuist/Interfaces/SwiftUI/Sources/Presentation/Modules/Landing/LandingViewModel.swift b/template/Tuist/Interfaces/SwiftUI/Sources/Presentation/Modules/Landing/LandingViewModel.swift index 349cb28c..79a2a8fa 100644 --- a/template/Tuist/Interfaces/SwiftUI/Sources/Presentation/Modules/Landing/LandingViewModel.swift +++ b/template/Tuist/Interfaces/SwiftUI/Sources/Presentation/Modules/Landing/LandingViewModel.swift @@ -22,11 +22,15 @@ final class LandingViewModel: ObservableObject { @Injected(\.loadStartupConfigUseCase) private var loadStartupConfigUseCase: any LoadStartupConfigUseCaseProtocol @Injected(\.sessionRepository) private var sessionRepository: any SessionRepositoryProtocol @Injected(\.checkForceUpdateUseCase) private var checkForceUpdateUseCase: any CheckForceUpdateUseCaseProtocol + @Injected(\.ratingPromptStorage) private var ratingPromptStorage: any RatingPromptStorageProtocol + @Injected(\.requestRatingPromptUseCase) private var requestRatingPromptUseCase: any RequestRatingPromptUseCaseProtocol private var hasRestoredSession = false func restoreSessionIfNeeded() async { guard !hasRestoredSession else { return } + ratingPromptStorage.recordAppLaunch() + do { startupConfigLoadResult = try await loadStartupConfigUseCase() } catch is CancellationError { @@ -42,12 +46,18 @@ final class LandingViewModel: ObservableObject { return } state = await sessionRepository.hasActiveSession() ? .signedIn : .signedOut + + if state == .signedIn { + await tryShowRatingPrompt() + } } func continueWithDemoSession() async { do { try await sessionRepository.save(tokenSet: DemoTokenSet()) + ratingPromptStorage.recordSignificantEvent() state = .signedIn + await tryShowRatingPrompt() } catch { state = .signedOut } @@ -59,6 +69,10 @@ final class LandingViewModel: ObservableObject { state = .signedOut } catch {} } + + private func tryShowRatingPrompt() async { + _ = await requestRatingPromptUseCase(configuration: RatingPromptConfiguration()) + } } private struct DemoTokenSet: TokenSetProtocol {