|
1 | 1 | // Copyright 2024 Google LLC
|
2 | 2 | //
|
3 |
| -// Licensed under the Apache-Version 2.0 (the "License"); |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
4 | 4 | // you may not use this file except in compliance with the License.
|
5 | 5 | // You may obtain a copy of the License at
|
6 | 6 | //
|
7 | 7 | // http://www.apache.org/licenses/LICENSE-2.0
|
8 | 8 | //
|
9 |
| -// Unless required by applicable law or agreed to in writing-software |
10 |
| -// distributed under the License is distributed on an "AS IS" BASIS- |
11 |
| -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND-either express or implied. |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | 12 | // See the License for the specific language governing permissions and
|
13 | 13 | // limitations under the License.
|
14 | 14 |
|
| 15 | +import XCTest |
15 | 16 | import FirebaseCore
|
16 | 17 | @testable import FirebaseRemoteConfig
|
17 | 18 |
|
18 |
| -import XCTest |
| 19 | +#if SWIFT_PACKAGE |
| 20 | + import RemoteConfigFakeConsoleObjC |
| 21 | +#endif |
| 22 | + |
| 23 | +// MARK: - Mock Objects for Testing |
| 24 | + |
| 25 | +/// A mock listener registration that allows tests to verify that its `remove()` method was called. |
| 26 | +class MockListenerRegistration: ConfigUpdateListenerRegistration, @unchecked Sendable { |
| 27 | + var wasRemoveCalled = false |
| 28 | + override func remove() { |
| 29 | + wasRemoveCalled = true |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +/// A mock for the RCNConfigRealtime component that allows tests to control the config update listener. |
| 34 | +class MockRealtime: RCNConfigRealtime { |
| 35 | + /// The listener closure captured from the `updates` async stream. |
| 36 | + var listener: ((RemoteConfigUpdate?, Error?) -> Void)? |
| 37 | + let mockRegistration = MockListenerRegistration() |
| 38 | + |
| 39 | + |
| 40 | + override func addConfigUpdateListener( |
| 41 | + _ listener: @escaping (RemoteConfigUpdate?, Error?) -> Void |
| 42 | + ) -> ConfigUpdateListenerRegistration { |
| 43 | + self.listener = listener |
| 44 | + return mockRegistration |
| 45 | + } |
| 46 | + |
| 47 | + /// Simulates the backend sending a successful configuration update. |
| 48 | + func sendUpdate(keys: [String]) { |
| 49 | + let update = RemoteConfigUpdate(updatedKeys: Set(keys)) |
| 50 | + listener?(update, nil) |
| 51 | + } |
| 52 | + |
| 53 | + /// Simulates the backend sending an error. |
| 54 | + func sendError(_ error: Error) { |
| 55 | + listener?(nil, error) |
| 56 | + } |
| 57 | + |
| 58 | + /// Simulates the listener completing without an update or error. |
| 59 | + func sendCompletion() { |
| 60 | + listener?(nil, nil) |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +// MARK: - AsyncStreamTests2 |
19 | 65 |
|
20 | 66 | @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
|
21 |
| -class AsyncStreamTests: APITestBase { |
22 |
| - func testConfigUpdateStreamReceivesUpdates() async throws { |
23 |
| - guard APITests.useFakeConfig else { return } |
24 |
| - |
25 |
| - let expectation = self.expectation(description: #function) |
| 67 | +class AsyncStreamTests: XCTestCase { |
| 68 | + var app: FirebaseApp! |
| 69 | + var config: RemoteConfig! |
| 70 | + var mockRealtime: MockRealtime! |
26 | 71 |
|
27 |
| - Task { |
| 72 | + struct TestError: Error, Equatable {} |
| 73 | + |
| 74 | + override func setUpWithError() throws { |
| 75 | + try super.setUpWithError() |
| 76 | + |
| 77 | + // Perform one-time setup of the FirebaseApp for testing. |
| 78 | + if FirebaseApp.app() == nil { |
| 79 | + let options = FirebaseOptions(googleAppID: "1:123:ios:123abc", |
| 80 | + gcmSenderID: "correct_gcm_sender_id") |
| 81 | + options.apiKey = "A23456789012345678901234567890123456789" |
| 82 | + options.projectID = "Fake_Project" |
| 83 | + FirebaseApp.configure(options: options) |
| 84 | + } |
| 85 | + |
| 86 | + app = FirebaseApp.app()! |
| 87 | + config = RemoteConfig.remoteConfig(app: app) |
| 88 | + |
| 89 | + // Install the mock realtime service. |
| 90 | + mockRealtime = MockRealtime() |
| 91 | + config.configRealtime = mockRealtime |
| 92 | + } |
| 93 | + |
| 94 | + override func tearDownWithError() throws { |
| 95 | + app = nil |
| 96 | + config = nil |
| 97 | + mockRealtime = nil |
| 98 | + try super.tearDownWithError() |
| 99 | + } |
| 100 | + |
| 101 | + func testStreamYieldsUpdate_whenUpdateIsSent() async throws { |
| 102 | + let expectation = self.expectation(description: "Stream should yield an update.") |
| 103 | + let keysToUpdate = ["foo", "bar"] |
| 104 | + |
| 105 | + let listeningTask = Task { |
28 | 106 | for try await update in config.updates {
|
| 107 | + XCTAssertEqual(update.updatedKeys, Set(keysToUpdate)) |
29 | 108 | expectation.fulfill()
|
| 109 | + break // End the loop after receiving the expected update. |
30 | 110 | }
|
31 | 111 | }
|
32 | 112 |
|
33 |
| - fakeConsole.config[Constants.key1] = Constants.value1 |
34 |
| - await fulfillment(of: [expectation], timeout: 5) |
| 113 | + // Ensure the listener is attached before sending the update. |
| 114 | + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds |
| 115 | + |
| 116 | + mockRealtime.sendUpdate(keys: keysToUpdate) |
| 117 | + |
| 118 | + await fulfillment(of: [expectation], timeout: 1.0) |
| 119 | + listeningTask.cancel() |
| 120 | + } |
| 121 | + |
| 122 | + func testStreamFinishes_whenErrorIsSent() async throws { |
| 123 | + let expectation = self.expectation(description: "Stream should throw an error.") |
| 124 | + let testError = TestError() |
| 125 | + |
| 126 | + let listeningTask = Task { |
| 127 | + do { |
| 128 | + for try await _ in config.updates { |
| 129 | + XCTFail("Stream should not have yielded any updates.") |
| 130 | + } |
| 131 | + } catch { |
| 132 | + XCTAssertEqual(error as? TestError, testError) |
| 133 | + expectation.fulfill() |
| 134 | + } |
| 135 | + } |
| 136 | + |
| 137 | + // Ensure the listener is attached before sending the error. |
| 138 | + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds |
| 139 | + |
| 140 | + mockRealtime.sendError(testError) |
| 141 | + |
| 142 | + await fulfillment(of: [expectation], timeout: 1.0) |
| 143 | + listeningTask.cancel() |
| 144 | + } |
| 145 | + |
| 146 | + func testStreamCancellation_callsRemoveOnListener() async throws { |
| 147 | + let listeningTask = Task { |
| 148 | + for try await _ in config.updates { |
| 149 | + // We will cancel the task, so it should not reach here. |
| 150 | + } |
| 151 | + } |
| 152 | + |
| 153 | + // Ensure the listener has time to be established. |
| 154 | + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds |
| 155 | + |
| 156 | + // Verify the listener has not been removed yet. |
| 157 | + XCTAssertFalse(mockRealtime.mockRegistration.wasRemoveCalled) |
| 158 | + |
| 159 | + // Cancel the task, which should trigger the stream's onTermination handler. |
| 160 | + listeningTask.cancel() |
| 161 | + |
| 162 | + // Give the cancellation a moment to propagate. |
| 163 | + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds |
| 164 | + |
| 165 | + // Verify the listener was removed. |
| 166 | + XCTAssertTrue(mockRealtime.mockRegistration.wasRemoveCalled) |
| 167 | + } |
| 168 | + |
| 169 | + func testStreamFinishesGracefully_whenListenerSendsNil() async throws { |
| 170 | + let expectation = self.expectation(description: "Stream should finish without error.") |
| 171 | + |
| 172 | + let listeningTask = Task { |
| 173 | + var updateCount = 0 |
| 174 | + do { |
| 175 | + for try await _ in config.updates { |
| 176 | + updateCount += 1 |
| 177 | + } |
| 178 | + // The loop finished without throwing, which is the success condition. |
| 179 | + XCTAssertEqual(updateCount, 0, "No updates should have been received.") |
| 180 | + expectation.fulfill() |
| 181 | + } catch { |
| 182 | + XCTFail("Stream should not have thrown an error, but threw \(error).") |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + try await Task.sleep(nanoseconds: 100_000_000) |
| 187 | + mockRealtime.sendCompletion() |
| 188 | + |
| 189 | + await fulfillment(of: [expectation], timeout: 1.0) |
| 190 | + listeningTask.cancel() |
| 191 | + } |
| 192 | + |
| 193 | + func testStreamYieldsMultipleUpdates_whenMultipleUpdatesAreSent() async throws { |
| 194 | + let expectation = self.expectation(description: "Stream should receive two updates.") |
| 195 | + expectation.expectedFulfillmentCount = 2 |
| 196 | + |
| 197 | + let updatesToSend = [ |
| 198 | + Set(["key1", "key2"]), |
| 199 | + Set(["key3"]), |
| 200 | + ] |
| 201 | + var receivedUpdates: [Set<String>] = [] |
| 202 | + |
| 203 | + let listeningTask = Task { |
| 204 | + for try await update in config.updates { |
| 205 | + receivedUpdates.append(update.updatedKeys) |
| 206 | + expectation.fulfill() |
| 207 | + if receivedUpdates.count == updatesToSend.count { |
| 208 | + break |
| 209 | + } |
| 210 | + } |
| 211 | + return receivedUpdates |
| 212 | + } |
| 213 | + |
| 214 | + try await Task.sleep(nanoseconds: 100_000_000) |
| 215 | + |
| 216 | + mockRealtime.sendUpdate(keys: Array(updatesToSend[0])) |
| 217 | + try await Task.sleep(nanoseconds: 100_000_000) // Brief pause between sends |
| 218 | + mockRealtime.sendUpdate(keys: Array(updatesToSend[1])) |
| 219 | + |
| 220 | + await fulfillment(of: [expectation], timeout: 2.0) |
| 221 | + |
| 222 | + let finalUpdates = try await listeningTask.value |
| 223 | + XCTAssertEqual(finalUpdates, updatesToSend) |
| 224 | + listeningTask.cancel() |
35 | 225 | }
|
36 | 226 | }
|
0 commit comments