Skip to content

Commit 02e8fb5

Browse files
committed
[#698] Add a foundation for Rating Prompt feature [PART 2]
1 parent 506e971 commit 02e8fb5

7 files changed

Lines changed: 930 additions & 0 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import Foundation
2+
3+
@testable import Data
4+
5+
final class UserDefaultsManagerMock: UserDefaultsManagerProtocol, @unchecked Sendable {
6+
7+
private var storage: [String: Any] = [:]
8+
private var synchronizeCallCount = 0
9+
10+
var didCallSynchronize: Bool { synchronizeCallCount > 0 }
11+
var synchronizeCallCountValue: Int { synchronizeCallCount }
12+
13+
func set(_ value: Any?, for key: String) {
14+
if let value = value {
15+
storage[key] = value
16+
} else {
17+
storage.removeValue(forKey: key)
18+
}
19+
}
20+
21+
func setObject<T: Codable>(_ value: T?, key: String) {
22+
if let value = value {
23+
storage[key] = value
24+
} else {
25+
storage.removeValue(forKey: key)
26+
}
27+
}
28+
29+
func getStringValue(for key: String) -> String? {
30+
return storage[key] as? String
31+
}
32+
33+
func getBooleanValue(for key: String) -> Bool {
34+
return storage[key] as? Bool ?? false
35+
}
36+
37+
func getIntValue(for key: String) -> Int {
38+
return storage[key] as? Int ?? 0
39+
}
40+
41+
func getArray(for key: String) -> [Any]? {
42+
return storage[key] as? [Any]
43+
}
44+
45+
func getDataValue(for key: String) -> Data? {
46+
return storage[key] as? Data
47+
}
48+
49+
func getObject<T: Codable>(ofType: T.Type, key: String) -> T? {
50+
return storage[key] as? T
51+
}
52+
53+
func getValue(for key: String) -> Any? {
54+
return storage[key]
55+
}
56+
57+
func getAllKeys(withPrefix prefix: String) -> [String] {
58+
return storage.keys.filter { $0.hasPrefix(prefix) }
59+
}
60+
61+
func clearData(forKeys keys: [String]) {
62+
keys.forEach { storage.removeValue(forKey: $0) }
63+
}
64+
65+
func clearDataForCommonKeys() {
66+
storage.removeAll()
67+
}
68+
69+
func synchronize() {
70+
synchronizeCallCount += 1
71+
}
72+
73+
// Test helper methods
74+
func reset() {
75+
storage.removeAll()
76+
synchronizeCallCount = 0
77+
}
78+
79+
func setStorageValue(_ value: Any?, forKey key: String) {
80+
storage[key] = value
81+
}
82+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import Data
5+
import Domain
6+
7+
@Suite("RatingPromptStorage")
8+
struct RatingPromptStorageTests {
9+
10+
@Test("getRatingPromptData returns data with default values when no data is stored")
11+
func getRatingPromptDataReturnsDataWithDefaultValuesWhenNoDataIsStored() {
12+
let userDefaultsManager = UserDefaultsManagerMock()
13+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
14+
15+
let data = storage.getRatingPromptData()
16+
17+
#expect(data.appLaunchCount == 0)
18+
#expect(data.firstLaunchDate == nil)
19+
#expect(data.lastPromptedVersion == nil)
20+
#expect(data.significantEventCount == 0)
21+
}
22+
23+
@Test("getRatingPromptData returns stored values")
24+
func getRatingPromptDataReturnsStoredValues() throws {
25+
let userDefaultsManager = UserDefaultsManagerMock()
26+
let testDate = Date()
27+
let encodedDate = try JSONEncoder().encode(testDate)
28+
29+
userDefaultsManager.setStorageValue(5, forKey: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
30+
userDefaultsManager.setStorageValue(encodedDate, forKey: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue)
31+
userDefaultsManager.setStorageValue("1.2.0", forKey: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue)
32+
userDefaultsManager.setStorageValue(3, forKey: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
33+
34+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
35+
let data = storage.getRatingPromptData()
36+
37+
#expect(data.appLaunchCount == 5)
38+
#expect(data.firstLaunchDate?.timeIntervalSince1970 == testDate.timeIntervalSince1970)
39+
#expect(data.lastPromptedVersion == "1.2.0")
40+
#expect(data.significantEventCount == 3)
41+
}
42+
43+
@Test("getRatingPromptData handles invalid date data gracefully")
44+
func getRatingPromptDataHandlesInvalidDateDataGracefully() {
45+
let userDefaultsManager = UserDefaultsManagerMock()
46+
let invalidDateData = Data([0x00, 0x01, 0x02]) // Invalid JSON for Date
47+
48+
userDefaultsManager.setStorageValue(invalidDateData, forKey: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue)
49+
50+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
51+
let data = storage.getRatingPromptData()
52+
53+
#expect(data.firstLaunchDate == nil)
54+
}
55+
56+
@Test("recordAppLaunch increments launch count")
57+
func recordAppLaunchIncrementsLaunchCount() {
58+
let userDefaultsManager = UserDefaultsManagerMock()
59+
userDefaultsManager.setStorageValue(5, forKey: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
60+
61+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
62+
storage.recordAppLaunch()
63+
64+
let updatedCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
65+
#expect(updatedCount == 6)
66+
#expect(userDefaultsManager.didCallSynchronize)
67+
}
68+
69+
@Test("recordAppLaunch sets first launch date when not already set")
70+
func recordAppLaunchSetsFirstLaunchDateWhenNotAlreadySet() throws {
71+
let userDefaultsManager = UserDefaultsManagerMock()
72+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
73+
74+
storage.recordAppLaunch()
75+
76+
let dateData = userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue)
77+
#expect(dateData != nil)
78+
79+
let decodedDate = try JSONDecoder().decode(Date.self, from: dateData!)
80+
let now = Date()
81+
// Allow for small time difference (within 1 second)
82+
#expect(abs(decodedDate.timeIntervalSince(now)) < 1.0)
83+
}
84+
85+
@Test("recordAppLaunch does not overwrite existing first launch date")
86+
func recordAppLaunchDoesNotOverwriteExistingFirstLaunchDate() throws {
87+
let userDefaultsManager = UserDefaultsManagerMock()
88+
let existingDate = Date().addingTimeInterval(-1000)
89+
let encodedExistingDate = try JSONEncoder().encode(existingDate)
90+
91+
userDefaultsManager.setStorageValue(encodedExistingDate, forKey: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue)
92+
93+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
94+
storage.recordAppLaunch()
95+
96+
let dateData = userDefaultsManager.getDataValue(for: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue)!
97+
let storedDate = try JSONDecoder().decode(Date.self, from: dateData)
98+
99+
#expect(storedDate.timeIntervalSince1970 == existingDate.timeIntervalSince1970)
100+
}
101+
102+
@Test("recordSignificantEvent increments significant event count")
103+
func recordSignificantEventIncrementsSignificantEventCount() {
104+
let userDefaultsManager = UserDefaultsManagerMock()
105+
userDefaultsManager.setStorageValue(3, forKey: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
106+
107+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
108+
storage.recordSignificantEvent()
109+
110+
let updatedCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
111+
#expect(updatedCount == 4)
112+
#expect(userDefaultsManager.didCallSynchronize)
113+
}
114+
115+
@Test("recordPromptShown stores the app version")
116+
func recordPromptShownStoresTheAppVersion() {
117+
let userDefaultsManager = UserDefaultsManagerMock()
118+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
119+
120+
storage.recordPromptShown(for: "2.1.0")
121+
122+
let storedVersion = userDefaultsManager.getStringValue(for: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue)
123+
#expect(storedVersion == "2.1.0")
124+
#expect(userDefaultsManager.didCallSynchronize)
125+
}
126+
127+
@Test("resetCounters resets launch and event counters to zero")
128+
func resetCountersResetsLaunchAndEventCountersToZero() {
129+
let userDefaultsManager = UserDefaultsManagerMock()
130+
userDefaultsManager.setStorageValue(10, forKey: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
131+
userDefaultsManager.setStorageValue(5, forKey: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
132+
133+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
134+
storage.resetCounters()
135+
136+
let launchCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
137+
let eventCount = userDefaultsManager.getIntValue(for: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
138+
139+
#expect(launchCount == 0)
140+
#expect(eventCount == 0)
141+
#expect(userDefaultsManager.didCallSynchronize)
142+
}
143+
144+
@Test("clearAllData removes all rating prompt related data")
145+
func clearAllDataRemovesAllRatingPromptRelatedData() throws {
146+
let userDefaultsManager = UserDefaultsManagerMock()
147+
let testDate = Date()
148+
let encodedDate = try JSONEncoder().encode(testDate)
149+
150+
// Set up initial data
151+
userDefaultsManager.setStorageValue(10, forKey: UserDefaultsKey.ratingPromptAppLaunchCount.rawValue)
152+
userDefaultsManager.setStorageValue(encodedDate, forKey: UserDefaultsKey.ratingPromptFirstLaunchDate.rawValue)
153+
userDefaultsManager.setStorageValue("1.0.0", forKey: UserDefaultsKey.ratingPromptLastPromptedVersion.rawValue)
154+
userDefaultsManager.setStorageValue(5, forKey: UserDefaultsKey.ratingPromptSignificantEventCount.rawValue)
155+
156+
let storage = RatingPromptStorage(userDefaultsManager: userDefaultsManager)
157+
storage.clearAllData()
158+
159+
// Verify all data is cleared
160+
let data = storage.getRatingPromptData()
161+
#expect(data.appLaunchCount == 0)
162+
#expect(data.firstLaunchDate == nil)
163+
#expect(data.lastPromptedVersion == nil)
164+
#expect(data.significantEventCount == 0)
165+
#expect(userDefaultsManager.didCallSynchronize)
166+
}
167+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import Foundation
2+
import Testing
3+
4+
@testable import Data
5+
import Domain
6+
7+
@Suite("DefaultRatingPromptPresenter")
8+
struct DefaultRatingPromptPresenterTests {
9+
10+
@Test("calls requestReview on StoreReviewController when show is called and returns result")
11+
func callsRequestReviewOnStoreReviewControllerWhenShowIsCalledAndReturnsResult() async {
12+
let storeReviewController = SpyStoreReviewController()
13+
let presenter = DefaultRatingPromptPresenter(storeReviewController: storeReviewController)
14+
15+
let result = await presenter.show()
16+
17+
let callCount = await storeReviewController.requestReviewCallCount
18+
#expect(callCount == 1)
19+
#expect(result == true)
20+
}
21+
22+
@Test("returns false when store review controller returns false")
23+
func returnsFalseWhenStoreReviewControllerReturnsFalse() async {
24+
let storeReviewController = SpyStoreReviewController(shouldReturnTrue: false)
25+
let presenter = DefaultRatingPromptPresenter(storeReviewController: storeReviewController)
26+
27+
let result = await presenter.show()
28+
29+
#expect(result == false)
30+
}
31+
32+
@Test("multiple calls to show result in multiple requestReview calls")
33+
func multipleCallsToShowResultInMultipleRequestReviewCalls() async {
34+
let storeReviewController = SpyStoreReviewController()
35+
let presenter = DefaultRatingPromptPresenter(storeReviewController: storeReviewController)
36+
37+
let result1 = await presenter.show()
38+
let result2 = await presenter.show()
39+
let result3 = await presenter.show()
40+
41+
let callCount = await storeReviewController.requestReviewCallCount
42+
#expect(callCount == 3)
43+
#expect(result1 == true)
44+
#expect(result2 == true)
45+
#expect(result3 == true)
46+
}
47+
}
48+
49+
// MARK: - Test Double
50+
51+
@MainActor
52+
private final class SpyStoreReviewController: StoreReviewControllerProtocol, @unchecked Sendable {
53+
54+
private(set) var requestReviewCallCount = 0
55+
private let shouldReturnTrue: Bool
56+
57+
init(shouldReturnTrue: Bool = true) {
58+
self.shouldReturnTrue = shouldReturnTrue
59+
}
60+
61+
func requestReview() async -> Bool {
62+
requestReviewCallCount += 1
63+
return shouldReturnTrue
64+
}
65+
}

0 commit comments

Comments
 (0)