Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions GrowthBookTests/FeaturesViewModelExtendedTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class FeaturesViewModelExtendedTests: XCTestCase {
// MARK: - Delegate capture

private class Capture: FeaturesFlowDelegate {

var successCount = 0
var failCount = 0
var savedGroupsCount = 0
Expand All @@ -27,6 +28,16 @@ class FeaturesViewModelExtendedTests: XCTestCase {
}
func savedGroupsFetchFailed(error: SDKError, isRemote: Bool) { failCount += 1 }
func featuresAPIModelSuccessfully(model: FeaturesDataModel) {}

// featuresUpdateIsComplete

var featuresUpdateIsCompleteCallCount = 0
var featuresUpdateIsCompleteArguments: [(error: GrowthBook.SDKError?, isRemote: Bool)] = []

func featuresUpdateIsComplete(error: GrowthBook.SDKError?, isRemote: Bool) {
featuresUpdateIsCompleteArguments += [(error, isRemote)]
featuresUpdateIsCompleteCallCount += 1
}
}

private func makeVM(
Expand Down Expand Up @@ -60,6 +71,7 @@ class FeaturesViewModelExtendedTests: XCTestCase {
// With preloadedFeatures, the VM skips the cache → no success or fail from cache
XCTAssertEqual(capture.successCount, 0)
XCTAssertEqual(capture.failCount, 0)
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 0)
}

func testNoPreloadedFeaturesReadsCache() {
Expand All @@ -68,6 +80,7 @@ class FeaturesViewModelExtendedTests: XCTestCase {
_ = makeVM(delegate: capture)
XCTAssertEqual(capture.failCount, 1)
XCTAssertEqual(capture.lastError, .failedToLoadData)
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 0)
}

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

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

func testFetchFeaturesRemoteEvalFailure() {
let capture = Capture()
let vm = makeVM(error: SDKError.failedToFetchData, delegate: capture, ttlSeconds: 0)
vm.fetchFeatures(apiUrl: "https://example.com", remoteEval: true)
XCTAssertGreaterThan(capture.failCount, 0)
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedToLoadData)
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
}

// MARK: - prepareFeaturesData: encryptedFeatures without key
Expand All @@ -111,6 +133,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
vm.prepareFeaturesData(data: payload)
XCTAssertGreaterThan(capture.failCount, 0)
XCTAssertEqual(capture.lastError, .failedMissingKey)
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedMissingKey)
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
}

func testPrepareFeaturesDataEncryptedFeaturesWithWrongKeyFails() {
Expand All @@ -123,6 +148,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
vm.prepareFeaturesData(data: payload)
XCTAssertGreaterThan(capture.failCount, 0)
XCTAssertEqual(capture.lastError, .failedEncryptedFeatures)
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedEncryptedFeatures)
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
}

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

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

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

// MARK: - prepareFeaturesData: savedGroups in response
Expand All @@ -172,6 +209,9 @@ class FeaturesViewModelExtendedTests: XCTestCase {
vm.prepareFeaturesData(data: payload)
XCTAssertEqual(capture.savedGroupsCount, 1)
XCTAssertNotNil(capture.lastSavedGroups)
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
XCTAssertNil(capture.featuresUpdateIsCompleteArguments[0].error)
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
}

// MARK: - fetchFeatures network failure falls back to cache
Expand Down Expand Up @@ -207,5 +247,32 @@ class FeaturesViewModelExtendedTests: XCTestCase {
failVM.fetchFeatures(apiUrl: "https://example.com")
// After network fail, falls back to cache → at least one success
XCTAssertGreaterThan(capture.successCount, 0)
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[0].error?.code, .failedToFetchData)
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)
}

// MARK: - fetchFeatures is not stale reports featuresAreUpToDate to the delegate

func testFetchFeaturesReportIfNotStale() {
// GIVEN
let capture = Capture()
let vm = makeVM(response: MockResponse().successResponse, delegate: capture, ttlSeconds: 100)
// first successful fetch
vm.fetchFeatures(apiUrl: "https://example.com", remoteEval: true)
XCTAssertGreaterThan(capture.successCount, 0)
// no featuresAreUpToDate callse
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 1)
XCTAssertNil(capture.featuresUpdateIsCompleteArguments[0].error)
XCTAssertTrue(capture.featuresUpdateIsCompleteArguments[0].isRemote)

// WHEN
// trying to fetch while cache is not expired
vm.fetchFeatures(apiUrl: "https://example.com", remoteEval: true)

// THEN
XCTAssertEqual(capture.featuresUpdateIsCompleteCallCount, 2, "Expected to call featuresAreUpToDate delegate method")
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[1].error, nil)
XCTAssertEqual(capture.featuresUpdateIsCompleteArguments[1].isRemote, true)
}
}
53 changes: 52 additions & 1 deletion GrowthBookTests/FeaturesViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
var isError: Bool = false
var hasFeatures: Bool = false
var ttlSeconds = 60

let cachingManager: CachingLayer = CachingManager()

override func setUp() {
Expand All @@ -30,6 +30,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
XCTAssertTrue(isSuccess)
XCTAssertFalse(isError)
XCTAssertTrue(hasFeatures)
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
}

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

XCTAssertTrue(isSuccess)
XCTAssertFalse(isError)
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
}

func testGetDataFromCache() throws {
Expand Down Expand Up @@ -80,6 +86,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
XCTAssertTrue(isSuccess)
XCTAssertFalse(isError)
XCTAssertTrue(hasFeatures)
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
}

func testWithEncryptGetDataFromCache() throws {
Expand Down Expand Up @@ -110,6 +119,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {

XCTAssertTrue(isSuccess)
XCTAssertFalse(isError)
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
}

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

XCTAssertNotNil(savedGroupsFromCache, "savedGroups should be restored from cache on restart")
XCTAssertFalse(savedGroupsFromCache?.dictionaryValue.isEmpty ?? true, "savedGroups should not be empty")
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)

XCTAssertEqual(captureDelegate.featuresUpdateIsCompleteCallCount, 0)
}

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

XCTAssertTrue(isSuccess)
XCTAssertFalse(isError)
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 2)
XCTAssertNil(featuresUpdateIsCompleteArguments[0].error)
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
XCTAssertEqual(featuresUpdateIsCompleteArguments[1].error, nil)
XCTAssertEqual(featuresUpdateIsCompleteArguments[1].isRemote, true)
}

func testError() throws {
Expand All @@ -167,6 +189,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
XCTAssertFalse(isSuccess)
XCTAssertTrue(isError)
XCTAssertFalse(hasFeatures)
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
XCTAssertEqual(featuresUpdateIsCompleteArguments[0].error?.code, .failedToFetchData)
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
}

func testInvalid() throws {
Expand All @@ -178,6 +203,9 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
XCTAssertFalse(isSuccess)
XCTAssertTrue(isError)
XCTAssertFalse(hasFeatures)
XCTAssertEqual(featuresUpdateIsCompleteCallCount, 1)
XCTAssertEqual(featuresUpdateIsCompleteArguments[0].error?.code, .failedMissingKey)
XCTAssertTrue(featuresUpdateIsCompleteArguments[0].isRemote)
}

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

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

}

// featuresUpdateIsComplete

var featuresUpdateIsCompleteCallCount = 0
var featuresUpdateIsCompleteArguments: [(error: GrowthBook.SDKError?, isRemote: Bool)] = []

func featuresUpdateIsComplete(error: GrowthBook.SDKError?, isRemote: Bool) {
featuresUpdateIsCompleteArguments += [(error, isRemote)]
featuresUpdateIsCompleteCallCount += 1
}
}

private class SavedGroupsCapture: FeaturesFlowDelegate {
Expand All @@ -247,4 +288,14 @@ private class SavedGroupsCapture: FeaturesFlowDelegate {
func savedGroupsFetchedSuccessfully(savedGroups: JSON, isRemote: Bool) {
onSavedGroups(savedGroups)
}

// featuresUpdateIsComplete

var featuresUpdateIsCompleteCallCount = 0
var featuresUpdateIsCompleteArguments: [(error: GrowthBook.SDKError?, isRemote: Bool)] = []

func featuresUpdateIsComplete(error: GrowthBook.SDKError?, isRemote: Bool) {
featuresUpdateIsCompleteArguments += [(error, isRemote)]
featuresUpdateIsCompleteCallCount += 1
}
}
35 changes: 35 additions & 0 deletions GrowthBookTests/GrowthBookSDKTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -336,4 +336,39 @@ class GrowthBookSDKTests: XCTestCase {
savedGroupsApplied = sdk.getGBContext().savedGroups != nil
XCTAssertTrue(savedGroupsApplied)
}

func testRunsRefreshHandler() {
// GIVEN
let expectation = XCTestExpectation(description: "Runs refresh handler even if features are cached")
expectation.expectedFulfillmentCount = 2
// 1 call - initializer.featuresUpdateIsComplete
// 2 call - refreshCache.featuresUpdateIsComplete
let cachingManager = CachingManager(apiKey: "isolated-savedgroups-test")
cachingManager.clearCache()

let sdk = GrowthBookBuilder(
growthBookBuilderModel: GrowthBookModel(
apiHost: apiHost, clientKey: "isolated-savedgroups-test",
attributes: JSON([:]), trackingClosure: { _, _ in },
backgroundSync: false
),
networkDispatcher: MockNetworkClient(
successResponse: MockResponse().successResponseNoGroups,
error: nil
),
ttlSeconds: 60,
cachingManager: cachingManager,
refreshHandler: { _ in
expectation.fulfill()
}
).initializer()

// WHEN
sdk.refreshCache()

// THEN
wait(for: [expectation], timeout: 2.0)

XCTAssertTrue(sdk.isOn(feature: "onboarding"))
}
}
32 changes: 31 additions & 1 deletion GrowthBookTests/MockNetworkClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,37 @@ class MockResponse {
"savedGroups": {"group_id": ["4", "5", "6"]}
}
""".trimmingCharacters(in: .whitespaces)


let successResponseNoGroups = """
{
"status": 200,
"features": {
"onboarding": {
"defaultValue": "top",
"rules": [
{
"condition": {
"id": "2435245",
"loggedIn": false
},
"variations": [
"top",
"bottom",
"center"
],
"weights": [
0.25,
0.5,
0.25
],
"hashAttribute": "id"
}
]
}
}
}
""".trimmingCharacters(in: .whitespaces)

/// Reproduces the payload shape reported by users after multiRange namespace support
/// was introduced: feature rules may now carry a `filters` array whose `ranges` items
/// are bare JSON arrays `[start, end]` rather than keyed objects.
Expand Down
Loading
Loading