mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-10 22:45:59 +05:45
Introduces a new `configUpdates` property to `RemoteConfig`, providing an `AsyncSequence` for consuming real-time configuration updates. This modern, Swift Concurrency-native API offers a cleaner alternative to the existing closure-based listener, allowing developers to handle config updates using `for await` loops. Key changes: - Added `RemoteConfig.configUpdates` returning `RemoteConfigUpdateSequence`. - Implemented `RemoteConfigUpdateSequence` with a built-in iterator that manages the lifecycle of the underlying listener. - Aligned implementation with the recently merged Firestore AsyncSequence patterns. - Comprehensive unit tests verified for listener registration, event yielding, and graceful termination.
242 lines
8.0 KiB
Swift
242 lines
8.0 KiB
Swift
// Copyright 2025 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
import FirebaseCore
|
|
@testable import FirebaseRemoteConfig
|
|
import XCTest
|
|
|
|
#if SWIFT_PACKAGE
|
|
import RemoteConfigFakeConsoleObjC
|
|
#endif
|
|
|
|
// MARK: - Mock Objects for Testing
|
|
|
|
/// A mock listener registration that allows tests to verify that its `remove()` method was called.
|
|
class MockListenerRegistration: ConfigUpdateListenerRegistration, @unchecked Sendable {
|
|
var wasRemoveCalled = false
|
|
override func remove() {
|
|
wasRemoveCalled = true
|
|
}
|
|
}
|
|
|
|
/// A mock for the RCNConfigRealtime component that allows tests to control the config update
|
|
/// listener.
|
|
class MockRealtime: RCNConfigRealtime, @unchecked Sendable {
|
|
/// The listener closure captured from the `configUpdates` async stream.
|
|
var listener: ((RemoteConfigUpdate?, Error?) -> Void)?
|
|
let mockRegistration = MockListenerRegistration()
|
|
var listenerAttachedExpectation: XCTestExpectation?
|
|
|
|
override func addConfigUpdateListener(_ listener: @escaping (RemoteConfigUpdate?, Error?)
|
|
-> Void) -> ConfigUpdateListenerRegistration {
|
|
self.listener = listener
|
|
listenerAttachedExpectation?.fulfill()
|
|
return mockRegistration
|
|
}
|
|
|
|
/// Simulates the backend sending a successful configuration update.
|
|
func sendUpdate(keys: [String]) {
|
|
let update = RemoteConfigUpdate(updatedKeys: Set(keys))
|
|
listener?(update, nil)
|
|
}
|
|
|
|
/// Simulates the backend sending an error.
|
|
func sendError(_ error: Error) {
|
|
listener?(nil, error)
|
|
}
|
|
|
|
/// Simulates the listener completing without an update or error.
|
|
func sendCompletion() {
|
|
listener?(nil, nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - AsyncSequenceTests
|
|
|
|
@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
|
|
class AsyncSequenceTests: XCTestCase {
|
|
var app: FirebaseApp!
|
|
var config: RemoteConfig!
|
|
var mockRealtime: MockRealtime!
|
|
|
|
struct TestError: Error, Equatable {}
|
|
|
|
override func setUpWithError() throws {
|
|
try super.setUpWithError()
|
|
|
|
// Perform one-time setup of the FirebaseApp for testing.
|
|
if FirebaseApp.app() == nil {
|
|
let options = FirebaseOptions(googleAppID: "1:123:ios:123abc",
|
|
gcmSenderID: "correct_gcm_sender_id")
|
|
options.apiKey = "A23456789012345678901234567890123456789"
|
|
options.projectID = "Fake_Project"
|
|
FirebaseApp.configure(options: options)
|
|
}
|
|
|
|
app = FirebaseApp.app()!
|
|
config = RemoteConfig.remoteConfig(app: app)
|
|
|
|
// Install the mock realtime service.
|
|
mockRealtime = MockRealtime()
|
|
config.configRealtime = mockRealtime
|
|
}
|
|
|
|
override func tearDownWithError() throws {
|
|
app = nil
|
|
config = nil
|
|
mockRealtime = nil
|
|
try super.tearDownWithError()
|
|
}
|
|
|
|
func testSequenceYieldsUpdate_whenUpdateIsSent() async throws {
|
|
let expectation = self.expectation(description: "Sequence should yield an update.")
|
|
let keysToUpdate = ["foo", "bar"]
|
|
|
|
let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
|
|
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
|
|
|
|
let listeningTask = Task {
|
|
for try await update in config.configUpdates {
|
|
XCTAssertEqual(update.updatedKeys, Set(keysToUpdate))
|
|
expectation.fulfill()
|
|
break // End the loop after receiving the expected update.
|
|
}
|
|
}
|
|
|
|
// Wait for the listener to be attached before sending the update.
|
|
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
|
|
|
|
mockRealtime.sendUpdate(keys: keysToUpdate)
|
|
|
|
await fulfillment(of: [expectation], timeout: 1.0)
|
|
listeningTask.cancel()
|
|
}
|
|
|
|
func testSequenceFinishes_whenErrorIsSent() async throws {
|
|
let expectation = self.expectation(description: "Sequence should throw an error.")
|
|
let testError = TestError()
|
|
|
|
let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
|
|
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
|
|
|
|
let listeningTask = Task {
|
|
do {
|
|
for try await _ in config.configUpdates {
|
|
XCTFail("Stream should not have yielded any updates.")
|
|
}
|
|
} catch {
|
|
XCTAssertEqual(error as? TestError, testError)
|
|
expectation.fulfill()
|
|
}
|
|
}
|
|
|
|
// Wait for the listener to be attached before sending the error.
|
|
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
|
|
|
|
mockRealtime.sendError(testError)
|
|
|
|
await fulfillment(of: [expectation], timeout: 1.0)
|
|
listeningTask.cancel()
|
|
}
|
|
|
|
func testSequenceCancellation_callsRemoveOnListener() async throws {
|
|
let listenerAttachedExpectation = expectation(description: "Listener should be attached.")
|
|
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
|
|
|
|
let listeningTask = Task {
|
|
for try await _ in config.configUpdates {
|
|
// We will cancel the task, so it should not reach here.
|
|
}
|
|
}
|
|
|
|
// Wait for the listener to be attached.
|
|
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
|
|
|
|
// Verify the listener has not been removed yet.
|
|
XCTAssertFalse(mockRealtime.mockRegistration.wasRemoveCalled)
|
|
|
|
// Cancel the task, which should trigger the stream's onTermination handler.
|
|
listeningTask.cancel()
|
|
|
|
// Give the cancellation a moment to propagate.
|
|
try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
|
|
|
// Verify the listener was removed.
|
|
XCTAssertTrue(mockRealtime.mockRegistration.wasRemoveCalled)
|
|
}
|
|
|
|
func testSequenceFinishesGracefully_whenListenerSendsNil() async throws {
|
|
let expectation = self.expectation(description: "Sequence should finish without error.")
|
|
|
|
let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
|
|
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
|
|
|
|
let listeningTask = Task {
|
|
var updateCount = 0
|
|
do {
|
|
for try await _ in config.configUpdates {
|
|
updateCount += 1
|
|
}
|
|
// The loop finished without throwing, which is the success condition.
|
|
XCTAssertEqual(updateCount, 0, "No updates should have been received.")
|
|
expectation.fulfill()
|
|
} catch {
|
|
XCTFail("Stream should not have thrown an error, but threw \(error).")
|
|
}
|
|
}
|
|
|
|
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
|
|
mockRealtime.sendCompletion()
|
|
|
|
await fulfillment(of: [expectation], timeout: 1.0)
|
|
listeningTask.cancel()
|
|
}
|
|
|
|
func testSequenceYieldsMultipleUpdates_whenMultipleUpdatesAreSent() async throws {
|
|
let expectation = self.expectation(description: "Sequence should receive two updates.")
|
|
expectation.expectedFulfillmentCount = 2
|
|
|
|
let updatesToSend = [
|
|
Set(["key1", "key2"]),
|
|
Set(["key3"]),
|
|
]
|
|
var receivedUpdates: [Set<String>] = []
|
|
|
|
let listenerAttachedExpectation = self.expectation(description: "Listener should be attached.")
|
|
mockRealtime.listenerAttachedExpectation = listenerAttachedExpectation
|
|
|
|
let listeningTask = Task {
|
|
for try await update in config.configUpdates {
|
|
receivedUpdates.append(update.updatedKeys)
|
|
expectation.fulfill()
|
|
if receivedUpdates.count == updatesToSend.count {
|
|
break
|
|
}
|
|
}
|
|
return receivedUpdates
|
|
}
|
|
|
|
await fulfillment(of: [listenerAttachedExpectation], timeout: 1.0)
|
|
|
|
mockRealtime.sendUpdate(keys: Array(updatesToSend[0]))
|
|
mockRealtime.sendUpdate(keys: Array(updatesToSend[1]))
|
|
|
|
await fulfillment(of: [expectation], timeout: 2.0)
|
|
|
|
let finalUpdates = try await listeningTask.value
|
|
XCTAssertEqual(finalUpdates, updatesToSend)
|
|
listeningTask.cancel()
|
|
}
|
|
}
|