-
Notifications
You must be signed in to change notification settings - Fork 18
[#698] Add a foundation for Rating Prompt feature [PART 2] #700
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
thinh2k1310
wants to merge
1
commit into
feature/#698-foundation-for-rating-prompt
Choose a base branch
from
feature/#698-foundation-for-rating-prompt-part-2
base: feature/#698-foundation-for-rating-prompt
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
82 changes: 82 additions & 0 deletions
82
template/Modules/Data/Tests/Sources/Mocks/UserDefaultsManagerMock.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<T: Codable>(_ 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<T: Codable>(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 | ||
| } | ||
| } | ||
167 changes: 167 additions & 0 deletions
167
template/Modules/Data/Tests/Sources/Repositories/RatingPromptStorageTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
| } |
65 changes: 65 additions & 0 deletions
65
template/Modules/Data/Tests/Sources/Services/DefaultRatingPromptPresenterTests.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
|
thinh2k1310 marked this conversation as resolved.
|
||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.