Skip to content

Commit 139e1b8

Browse files
authored
chore: call refreshHandler once and even if features are up to date (#162)
* chore: call refreshHandler once and even if features are up to date * chore: fixed trailing coma in function parameters * chore: remove unneded changes * chore: fix typo * chore: always call the refreshHandler
1 parent 3d091b0 commit 139e1b8

7 files changed

Lines changed: 255 additions & 39 deletions

File tree

GrowthBookTests/FeaturesViewModelExtendedTests.swift

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class FeaturesViewModelExtendedTests: XCTestCase {
66
// MARK: - Delegate capture
77

88
private class Capture: FeaturesFlowDelegate {
9+
910
var successCount = 0
1011
var failCount = 0
1112
var savedGroupsCount = 0
@@ -27,6 +28,16 @@ class FeaturesViewModelExtendedTests: XCTestCase {
2728
}
2829
func savedGroupsFetchFailed(error: SDKError, isRemote: Bool) { failCount += 1 }
2930
func featuresAPIModelSuccessfully(model: FeaturesDataModel) {}
31+
32+
// featuresUpdateIsComplete
33+
34+
var featuresUpdateIsCompleteCallCount = 0
35+
var featuresUpdateIsCompleteArguments: [(error: GrowthBook.SDKError?, isRemote: Bool)] = []
36+
37+
func featuresUpdateIsComplete(error: GrowthBook.SDKError?, isRemote: Bool) {
38+
featuresUpdateIsCompleteArguments += [(error, isRemote)]
39+
featuresUpdateIsCompleteCallCount += 1
40+
}
3041
}
3142

3243
private func makeVM(
@@ -60,6 +71,7 @@ class FeaturesViewModelExtendedTests: XCTestCase {
6071
// With preloadedFeatures, the VM skips the cache → no success or fail from cache
6172
XCTAssertEqual(capture.successCount, 0)
6273
XCTAssertEqual(capture.failCount, 0)
74+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 0)
6375
}
6476

6577
func testNoPreloadedFeaturesReadsCache() {
@@ -68,6 +80,7 @@ class FeaturesViewModelExtendedTests: XCTestCase {
6880
_ = makeVM(delegate: capture)
6981
XCTAssertEqual(capture.failCount, 1)
7082
XCTAssertEqual(capture.lastError, .failedToLoadData)
83+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 0)
7184
}
7285

7386
// MARK: - fetchFeatures with nil apiUrl skips network
@@ -80,6 +93,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
8093
// No network call → no additional success
8194
XCTAssertEqual(capture.successCount, 0)
8295
XCTAssertEqual(capture.failCount, beforeFail + 1) // one more fail from cache read in fetchFeatures
96+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1) // fetchFeatures
97+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error, .invalidAPIURL)
98+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].isRemote, false)
8399
}
84100

85101
// MARK: - remoteEval path
@@ -90,13 +106,19 @@ class FeaturesViewModelExtendedTests: XCTestCase {
90106
vm.fetchFeatures(apiUrl: "https://example.com", remoteEval: true)
91107
// Remote eval success should trigger featuresFetchedSuccessfully
92108
XCTAssertGreaterThan(capture.successCount, 0)
109+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
110+
XCTAssertNil(capture.featuresUpdateIsCompleteArguments[0].error)
111+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
93112
}
94113

95114
func testFetchFeaturesRemoteEvalFailure() {
96115
let capture = Capture()
97116
let vm = makeVM(error: SDKError.failedToFetchData, delegate: capture, ttlSeconds: 0)
98117
vm.fetchFeatures(apiUrl: "https://example.com", remoteEval: true)
99118
XCTAssertGreaterThan(capture.failCount, 0)
119+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
120+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedToLoadData)
121+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
100122
}
101123

102124
// MARK: - prepareFeaturesData: encryptedFeatures without key
@@ -111,6 +133,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
111133
vm.prepareFeaturesData(data: payload)
112134
XCTAssertGreaterThan(capture.failCount, 0)
113135
XCTAssertEqual(capture.lastError, .failedMissingKey)
136+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
137+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedMissingKey)
138+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
114139
}
115140

116141
func testPrepareFeaturesDataEncryptedFeaturesWithWrongKeyFails() {
@@ -123,6 +148,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
123148
vm.prepareFeaturesData(data: payload)
124149
XCTAssertGreaterThan(capture.failCount, 0)
125150
XCTAssertEqual(capture.lastError, .failedEncryptedFeatures)
151+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
152+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedEncryptedFeatures)
153+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
126154
}
127155

128156
// MARK: - prepareFeaturesData: no features and no encryptedFeatures
@@ -136,6 +164,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
136164
vm.prepareFeaturesData(data: payload)
137165
XCTAssertGreaterThan(capture.failCount, 0)
138166
XCTAssertEqual(capture.lastError, .failedMissingKey)
167+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
168+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedMissingKey)
169+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
139170
}
140171

141172
// MARK: - prepareFeaturesData: plain features success
@@ -149,6 +180,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
149180
vm.prepareFeaturesData(data: payload)
150181
XCTAssertEqual(capture.successCount, 1)
151182
XCTAssertNotNil(capture.lastFeatures?["my-flag"])
183+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
184+
XCTAssertNil(capture.featuresUpdateIsCompleteArguments[0].error)
185+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
152186
}
153187

154188
// MARK: - prepareFeaturesData: invalid JSON fails
@@ -159,6 +193,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
159193
vm.prepareFeaturesData(data: "not json".data(using: .utf8)!)
160194
XCTAssertGreaterThan(capture.failCount, 0)
161195
XCTAssertEqual(capture.lastError, .failedParsedData)
196+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
197+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedParsedData)
198+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
162199
}
163200

164201
// MARK: - prepareFeaturesData: savedGroups in response
@@ -172,6 +209,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
172209
vm.prepareFeaturesData(data: payload)
173210
XCTAssertEqual(capture.savedGroupsCount, 1)
174211
XCTAssertNotNil(capture.lastSavedGroups)
212+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
213+
XCTAssertNil(capture.featuresUpdateIsCompleteArguments[0].error)
214+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
175215
}
176216

177217
// MARK: - fetchFeatures network failure falls back to cache
@@ -207,5 +247,32 @@ class FeaturesViewModelExtendedTests: XCTestCase {
207247
failVM.fetchFeatures(apiUrl: "https://example.com")
208248
// After network fail, falls back to cache → at least one success
209249
XCTAssertGreaterThan(capture.successCount, 0)
250+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
251+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedToFetchData)
252+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
253+
}
254+
255+
// MARK: - fetchFeatures is not stale reports featuresAreUpToDate to the delegate
256+
257+
func testFetchFeaturesReportIfNotStale() {
258+
// GIVEN
259+
let capture = Capture()
260+
let vm = makeVM(response: MockResponse().successResponse, delegate: capture, ttlSeconds: 100)
261+
// first successful fetch
262+
vm.fetchFeatures(apiUrl: "https://example.com", remoteEval: true)
263+
XCTAssertGreaterThan(capture.successCount, 0)
264+
// no featuresAreUpToDate callse
265+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
266+
XCTAssertNil(capture.featuresUpdateIsCompleteArguments[0].error)
267+
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
268+
269+
// WHEN
270+
// trying to fetch while cache is not expired
271+
vm.fetchFeatures(apiUrl: "https://example.com", remoteEval: true)
272+
273+
// THEN
274+
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 2, "Expected to call featuresAreUpToDate delegate method")
275+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[1].error, nil)
276+
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[1].isRemote, true)
210277
}
211278
}

GrowthBookTests/FeaturesViewModelTests.swift

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
88
var isError: Bool = false
99
var hasFeatures: Bool = false
1010
var ttlSeconds = 60
11-
11+
1212
let cachingManager: CachingLayer = CachingManager()
1313

1414
override func setUp() {
@@ -30,6 +30,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
3030
XCTAssertTrue(isSuccess)
3131
XCTAssertFalse(isError)
3232
XCTAssertTrue(hasFeatures)
33+
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
34+
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
35+
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
3336
}
3437

3538
func testSuccessForEncryptedFeatures() throws {
@@ -43,6 +46,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
4346

4447
XCTAssertTrue(isSuccess)
4548
XCTAssertFalse(isError)
49+
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
50+
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
51+
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
4652
}
4753

4854
func testGetDataFromCache() throws {
@@ -80,6 +86,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
8086
XCTAssertTrue(isSuccess)
8187
XCTAssertFalse(isError)
8288
XCTAssertTrue(hasFeatures)
89+
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
90+
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
91+
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
8392
}
8493

8594
func testWithEncryptGetDataFromCache() throws {
@@ -110,6 +119,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
110119

111120
XCTAssertTrue(isSuccess)
112121
XCTAssertFalse(isError)
122+
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
123+
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
124+
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
113125
}
114126

115127
func testSavedGroupsRestoredFromCacheOnRestart() throws {
@@ -136,6 +148,11 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
136148

137149
XCTAssertNotNil(savedGroupsFromCache, "savedGroups should be restored from cache on restart")
138150
XCTAssertFalse(savedGroupsFromCache?.dictionaryValue.isEmpty ?? true, "savedGroups should not be empty")
151+
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
152+
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
153+
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
154+
155+
XCTAssertEqual(captureDelegate.featuresUpdateIsCompleteCallCount, 0)
139156
}
140157

141158
func test304NotModifiedTreatedAsSuccess() throws {
@@ -153,6 +170,11 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
153170

154171
XCTAssertTrue(isSuccess)
155172
XCTAssertFalse(isError)
173+
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 2)
174+
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
175+
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
176+
XCTAssertEqual(featuresUpdateIsCompleteArguments[1].error, nil)
177+
XCTAssertEqual(featuresUpdateIsCompleteArguments[1].isRemote, true)
156178
}
157179

158180
func testError() throws {
@@ -167,6 +189,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
167189
XCTAssertFalse(isSuccess)
168190
XCTAssertTrue(isError)
169191
XCTAssertFalse(hasFeatures)
192+
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
193+
XCTAssertEqual(featuresUpdateIsCompleteArguments[0].error?.code, .failedToFetchData)
194+
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
170195
}
171196

172197
func testInvalid() throws {
@@ -178,6 +203,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
178203
XCTAssertFalse(isSuccess)
179204
XCTAssertTrue(isError)
180205
XCTAssertFalse(hasFeatures)
206+
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
207+
XCTAssertEqual(featuresUpdateIsCompleteArguments[0].error?.code, .failedMissingKey)
208+
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
181209
}
182210

183211
/// Regression test: payloads that include `filters` with array-encoded `ranges`
@@ -204,6 +232,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
204232
XCTAssertTrue(isSuccess, "Expected successful feature fetch with filters payload")
205233
XCTAssertFalse(isError)
206234
XCTAssertTrue(hasFeatures)
235+
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
236+
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
237+
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
207238
}
208239

209240
func featuresFetchedSuccessfully(features: Features, isRemote: Bool) {
@@ -231,6 +262,16 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
231262
func featuresAPIModelSuccessfully(model: FeaturesDataModel) {
232263

233264
}
265+
266+
// featuresUpdateIsComplete
267+
268+
var featuresUpdateIsCompleteCallCount = 0
269+
var featuresUpdateIsCompleteArguments: [(error: GrowthBook.SDKError?, isRemote: Bool)] = []
270+
271+
func featuresUpdateIsComplete(error: GrowthBook.SDKError?, isRemote: Bool) {
272+
featuresUpdateIsCompleteArguments += [(error, isRemote)]
273+
featuresUpdateIsCompleteCallCount += 1
274+
}
234275
}
235276

236277
private class SavedGroupsCapture: FeaturesFlowDelegate {
@@ -247,4 +288,14 @@ private class SavedGroupsCapture: FeaturesFlowDelegate {
247288
func savedGroupsFetchedSuccessfully(savedGroups: JSON, isRemote: Bool) {
248289
onSavedGroups(savedGroups)
249290
}
291+
292+
// featuresUpdateIsComplete
293+
294+
var featuresUpdateIsCompleteCallCount = 0
295+
var featuresUpdateIsCompleteArguments: [(error: GrowthBook.SDKError?, isRemote: Bool)] = []
296+
297+
func featuresUpdateIsComplete(error: GrowthBook.SDKError?, isRemote: Bool) {
298+
featuresUpdateIsCompleteArguments += [(error, isRemote)]
299+
featuresUpdateIsCompleteCallCount += 1
300+
}
250301
}

GrowthBookTests/GrowthBookSDKTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,39 @@ class GrowthBookSDKTests: XCTestCase {
336336
savedGroupsApplied = sdk.getGBContext().savedGroups != nil
337337
XCTAssertTrue(savedGroupsApplied)
338338
}
339+
340+
func testRunsRefreshHandler() {
341+
// GIVEN
342+
let expectation = XCTestExpectation(description: "Runs refresh handler even if features are cached")
343+
expectation.expectedFulfillmentCount = 2
344+
// 1 call - initializer.featuresUpdateIsComplete
345+
// 2 call - refreshCache.featuresUpdateIsComplete
346+
let cachingManager = CachingManager(apiKey: "isolated-savedgroups-test")
347+
cachingManager.clearCache()
348+
349+
let sdk = GrowthBookBuilder(
350+
growthBookBuilderModel: GrowthBookModel(
351+
apiHost: apiHost, clientKey: "isolated-savedgroups-test",
352+
attributes: JSON([:]), trackingClosure: { _, _ in },
353+
backgroundSync: false
354+
),
355+
networkDispatcher: MockNetworkClient(
356+
successResponse: MockResponse().successResponseNoGroups,
357+
error: nil
358+
),
359+
ttlSeconds: 60,
360+
cachingManager: cachingManager,
361+
refreshHandler: { _ in
362+
expectation.fulfill()
363+
}
364+
).initializer()
365+
366+
// WHEN
367+
sdk.refreshCache()
368+
369+
// THEN
370+
wait(for: [expectation], timeout: 2.0)
371+
372+
XCTAssertTrue(sdk.isOn(feature: "onboarding"))
373+
}
339374
}

GrowthBookTests/MockNetworkClient.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,37 @@ class MockResponse {
216216
"savedGroups": {"group_id": ["4", "5", "6"]}
217217
}
218218
""".trimmingCharacters(in: .whitespaces)
219-
219+
220+
let successResponseNoGroups = """
221+
{
222+
"status": 200,
223+
"features": {
224+
"onboarding": {
225+
"defaultValue": "top",
226+
"rules": [
227+
{
228+
"condition": {
229+
"id": "2435245",
230+
"loggedIn": false
231+
},
232+
"variations": [
233+
"top",
234+
"bottom",
235+
"center"
236+
],
237+
"weights": [
238+
0.25,
239+
0.5,
240+
0.25
241+
],
242+
"hashAttribute": "id"
243+
}
244+
]
245+
}
246+
}
247+
}
248+
""".trimmingCharacters(in: .whitespaces)
249+
220250
/// Reproduces the payload shape reported by users after multiRange namespace support
221251
/// was introduced: feature rules may now carry a `filters` array whose `ranges` items
222252
/// are bare JSON arrays `[start, end]` rather than keyed objects.

0 commit comments

Comments
 (0)