diff --git a/FirebaseRemoteConfig/SwiftNew/ConfigDBManager.swift b/FirebaseRemoteConfig/SwiftNew/ConfigDBManager.swift index 21ac6c4e239..9096fbba39c 100644 --- a/FirebaseRemoteConfig/SwiftNew/ConfigDBManager.swift +++ b/FirebaseRemoteConfig/SwiftNew/ConfigDBManager.swift @@ -52,12 +52,12 @@ open class ConfigDBManager: NSObject { /// Shared Singleton Instance @objc public static let sharedInstance = ConfigDBManager() - private let databaseActor: DatabaseActor - - @objc public var isNewDatabase: Bool = false + let databaseActor: DatabaseActor + @objc public var isNewDatabase: Bool @objc public init(dbPath: String = remoteConfigPathForDatabase()) { databaseActor = DatabaseActor(dbPath: dbPath) + isNewDatabase = !FileManager.default.fileExists(atPath: dbPath) super.init() } @@ -306,51 +306,31 @@ open class ConfigDBManager: NSObject { func deleteRecord(fromMainTableWithNamespace namespace: String, bundleIdentifier: String, fromSource source: DBSource) { - let params = [bundleIdentifier, namespace] - let sql = - if source == .default { - "DELETE FROM main_default WHERE bundle_identifier = ? and namespace = ?" - } else if source == .active { - "DELETE FROM main_active WHERE bundle_identifier = ? and namespace = ?" - } else { - "DELETE FROM main WHERE bundle_identifier = ? and namespace = ?" - } Task { - await self.databaseActor.executeQuery(sql, withParams: params) + await self.databaseActor + .deleteRecord( + fromMainTableWithNamespace: namespace, + bundleIdentifier: bundleIdentifier, + fromSource: source + ) } } @objc public func deleteRecord(withBundleIdentifier bundleIdentifier: String, namespace: String) { - let sql = "DELETE FROM fetch_metadata_v2 WHERE bundle_identifier = ? and namespace = ?" - let params = [bundleIdentifier, namespace] - Task { - await self.databaseActor.executeQuery(sql, withParams: params) - } - } - - @objc public - func deleteAllRecords(fromTableWithSource source: DBSource) { - let sql = - if source == .default { - "DELETE FROM main_default" - } else if source == .active { - "DELETE FROM main_active" - } else { - "DELETE FROM main" - } Task { - await self.databaseActor.executeQuery(sql) + await self.databaseActor.deleteRecord( + withBundleIdentifier: bundleIdentifier, + namespace: namespace + ) } } @objc public func deleteExperimentTable(forKey key: String) { - let params = [key] - let sql = "DELETE FROM experiment WHERE key = ?" Task { - await self.databaseActor.executeQuery(sql, withParams: params) + await self.databaseActor.deleteExperimentTable(forKey: key) } } diff --git a/FirebaseRemoteConfig/SwiftNew/DatabaseActor.swift b/FirebaseRemoteConfig/SwiftNew/DatabaseActor.swift index f544f7f40ac..2279746208b 100644 --- a/FirebaseRemoteConfig/SwiftNew/DatabaseActor.swift +++ b/FirebaseRemoteConfig/SwiftNew/DatabaseActor.swift @@ -23,8 +23,7 @@ private let RCNDatabaseName = "RemoteConfig.sqlite3" // Actor for database operations actor DatabaseActor { private var database: OpaquePointer? - private var isNewDatabase: Bool = false - private let dbPath: String + let dbPath: String init(dbPath: String) { self.dbPath = dbPath @@ -220,8 +219,8 @@ actor DatabaseActor { return logError(withSQL: sql, finalizeStatement: statement, returnValue: false) } if bindText(statement, 1, key) != SQLITE_OK || - sqlite3_bind_blob(statement, 2, (value as NSData).bytes, Int32(value.count), nil) != SQLITE_OK - { + sqlite3_bind_blob(statement, 2, (value as NSData).bytes, Int32(value.count), nil) != + SQLITE_OK { return logError(withSQL: sql, finalizeStatement: statement, returnValue: false) } if sqlite3_step(statement) != SQLITE_DONE { @@ -537,7 +536,7 @@ actor DatabaseActor { return results } - func loadRolloutTable(fromKey key: String) -> [[String: Any]] { + func loadRolloutTable(fromKey key: String) -> [[String: Sendable]] { let sql = "SELECT value FROM rollout WHERE key = ?" var statement: OpaquePointer? if sqlite3_prepare_v2(database, sql, -1, &statement, nil) != SQLITE_OK { @@ -559,11 +558,11 @@ actor DatabaseActor { if let data = results.first { // Convert from NSData to NSArray if let rollout = try? JSONSerialization - .jsonObject(with: data, options: []) as? [[String: Any]] { + .jsonObject(with: data, options: []) as? [[String: Sendable]] { return rollout } else { RCLog.error("I-RCN000011", - "Failed to convert NSData to NSAarry for Rollout Metadata") + "Failed to convert NSData to NSArray for Rollout Metadata") } } @@ -659,7 +658,6 @@ actor DatabaseActor { } let fileManager = FileManager.default if !fileManager.fileExists(atPath: filePath) { - isNewDatabase = true do { try fileManager.createDirectory( atPath: URL(fileURLWithPath: filePath).deletingLastPathComponent().path, @@ -764,11 +762,57 @@ actor DatabaseActor { executeQuery(createRollout) } - func removeDatabase(atPath path: String) { + // MARK: - Delete + + func deleteRecord(fromMainTableWithNamespace namespace: String, + bundleIdentifier: String, + fromSource source: DBSource) { + let params = [bundleIdentifier, namespace] + let sql = + if source == .default { + "DELETE FROM main_default WHERE bundle_identifier = ? and namespace = ?" + } else if source == .active { + "DELETE FROM main_active WHERE bundle_identifier = ? and namespace = ?" + } else { + "DELETE FROM main WHERE bundle_identifier = ? and namespace = ?" + } + executeQuery(sql, withParams: params) + } + + func deleteRecord(withBundleIdentifier bundleIdentifier: String, + namespace: String) { + let sql = "DELETE FROM fetch_metadata_v2 WHERE bundle_identifier = ? and namespace = ?" + let params = [bundleIdentifier, namespace] + executeQuery(sql, withParams: params) + } + + func deleteAllRecords(fromTableWithSource source: DBSource) { + let sql = + if source == .default { + "DELETE FROM main_default" + } else if source == .active { + "DELETE FROM main_active" + } else { + "DELETE FROM main" + } + executeQuery(sql) + } + + func deleteExperimentTable(forKey key: String) { + let params = [key] + let sql = "DELETE FROM experiment WHERE key = ?" + executeQuery(sql, withParams: params) + } + + func closeDatabase(atPath path: String) { if sqlite3_close(database) != SQLITE_OK { logDatabaseError() } database = nil + } + + func removeDatabase(atPath path: String) { + closeDatabase(atPath: path) do { try FileManager.default.removeItem(atPath: path) @@ -778,17 +822,24 @@ actor DatabaseActor { } } + @discardableResult func executeQuery(_ sql: String) -> Bool { var error: UnsafeMutablePointer? if sqlite3_exec(database, sql, nil, nil, &error) != SQLITE_OK { - RCLog.error("I-RCN000012", - "Failed to execute query with error \(error!).") + if let error { + RCLog.error("I-RCN000012", + "Failed to execute query with error \(error).") + } else { + RCLog.error("I-RCN000012", + "Failed to execute query with no error.") + } sqlite3_free(error) return false } return true } + @discardableResult func executeQuery(_ sql: String, withParams params: [String]) -> Bool { var statement: OpaquePointer? = nil defer { sqlite3_finalize(statement) } diff --git a/FirebaseRemoteConfig/Tests/SwiftUnit/ConfigDBManagerTest.swift b/FirebaseRemoteConfig/Tests/SwiftUnit/ConfigDBManagerTest.swift new file mode 100644 index 00000000000..02d302de46a --- /dev/null +++ b/FirebaseRemoteConfig/Tests/SwiftUnit/ConfigDBManagerTest.swift @@ -0,0 +1,411 @@ +// Copyright 2024 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 Dispatch +@testable import FirebaseRemoteConfig +import XCTest + +class ConfigDBManagerTest: XCTestCase { + let dbManager = ConfigDBManager() + let bundleID = Bundle.main.bundleIdentifier! + let namespace = "namespace" + var filePath: String { ConfigDBManager.remoteConfigPathForDatabase() } + + override func setUp() { + super.setUp() + removeDatabase() + createOrOpenDatabaseBlock() + } + + override func tearDown() { + removeDatabase() + super.tearDown() + } + + func testCreateOrOpenDBSuccess() { + XCTAssertTrue(FileManager.default.fileExists(atPath: filePath)) + } + + func testIsNewDatabase() async throws { + // For a newly created DB, isNewDatabase should be true + let isNew = dbManager.isNewDatabase + XCTAssertTrue(isNew) + } + + func testLoadMainTableWithBundleIdentifier() throws { + let config = [ + "key1": "value1", + "key2": "value2", + ] + let data = try JSONSerialization.data(withJSONObject: config, options: []) + let rcValue = RemoteConfigValue(data: data, source: .remote) + let namespace = "namespace" + + let exp = expectation(description: #function) + dbManager.insertMainTable(withValues: [bundleID, namespace, "key", rcValue.dataValue], + fromSource: .fetched) { success, _ in + XCTAssertTrue(success) + + self.dbManager.loadMain(withBundleIdentifier: self.bundleID) { success, fetched, _, _, _ in + XCTAssert(success) + XCTAssertTrue( + fetched[namespace]?["key"]?.stringValue == "{\"key1\":\"value1\",\"key2\":\"value2\"}" || + fetched[namespace]?["key"]?.stringValue == "{\"key2\":\"value2\",\"key1\":\"value1\"}" + ) + exp.fulfill() + } + } + waitForExpectations() + } + + func testLoadMainTableWithBundleIdentifier_noData() { + let exp = expectation(description: #function) + dbManager.loadMain(withBundleIdentifier: bundleID) { success, fetched, _, _, _ in + XCTAssert(success) // success should still be true. + XCTAssertEqual(fetched, [:]) + exp.fulfill() + } + waitForExpectations() + } + + func testLoadMetadataTableWithBundleIdentifier() throws { + let exp = expectation(description: #function) + let deviceContext = ["device": "info"] + let appContext = ["app": "info"] + let digestPerNamespace = ["digest": "info"] + let deviceContextData = try JSONSerialization.data(withJSONObject: deviceContext, options: []) + let appContextData = try JSONSerialization.data(withJSONObject: appContext, options: []) + let digestPerNamespaceData = try JSONSerialization.data(withJSONObject: digestPerNamespace, + options: []) + let columnNameToValue = try [ + RCNKeyBundleIdentifier: bundleID, + RCNKeyNamespace: namespace, + RCNKeyFetchTime: Date().timeIntervalSince1970, + RCNKeyDigestPerNamespace: digestPerNamespaceData, + RCNKeyDeviceContext: deviceContextData, + RCNKeyAppContext: appContextData, + RCNKeySuccessFetchTime: JSONSerialization.data(withJSONObject: [], options: []), + RCNKeyFailureFetchTime: JSONSerialization.data(withJSONObject: [], options: []), + RCNKeyLastFetchStatus: 0, + RCNKeyLastFetchError: 0, + RCNKeyLastApplyTime: Date().timeIntervalSince1970, + RCNKeyLastSetDefaultsTime: Date().timeIntervalSince1970, + ] as [String: Any] + dbManager.insertMetadataTable(withValues: columnNameToValue) { success, _ in + XCTAssertTrue(success) + self.dbManager.loadMetadata(withBundleIdentifier: self.bundleID, + namespace: self.namespace) { result in + XCTAssertEqual(result[RCNKeyBundleIdentifier] as? String, self.bundleID) + exp.fulfill() + } + } + waitForExpectations() + } + + func testLoadMetadataTableWithBundleIdentifierAndNamespace_noData() { + let exp = expectation(description: #function) + dbManager.loadMetadata(withBundleIdentifier: bundleID, namespace: namespace) { result in + XCTAssertTrue(result.isEmpty) + exp.fulfill() + } + waitForExpectations() + } + + func testUpdateMetadataTable() async throws { + try await insertMetadata() + let values: [Any] = [1, 1] // Fetch Failure status. + + let success = await dbManager.databaseActor.updateMetadataTable(withOption: .fetchStatus, + namespace: namespace, + values: values) + XCTAssertTrue(success) + let result = await dbManager.databaseActor.loadMetadataTable( + withBundleIdentifier: bundleID, + namespace: namespace + ) + XCTAssertEqual(result[RCNKeyLastFetchStatus] as? Int, 1) + XCTAssertEqual(result[RCNKeyLastFetchError] as? Int, 1) + } + + func testInsertInternalMetadataTable() { + let exp = expectation(description: #function) + let values: [Any] = [ + "\(bundleID):\(namespace):fetch_timeout", + "100.2".data(using: .utf8)!, + ] + dbManager.insertInternalMetadataTable(withValues: values) { success, _ in + XCTAssert(success) + self.dbManager.loadInternalMetadataTable { result in + XCTAssertEqual(result.count, 1) + XCTAssertEqual(String(data: result[values[0] as! String]!, encoding: .utf8), "100.2") + exp.fulfill() + } + } + waitForExpectations() + } + + func testLoadInternalMetadataTable_noData() { + let exp = expectation(description: #function) + dbManager.loadInternalMetadataTable { result in + XCTAssertTrue(result.isEmpty) + exp.fulfill() + } + waitForExpectations() + } + + func testDeleteRecordsFromMainTableForNamespace() async throws { + try await insertMainTableValue() + + await dbManager.databaseActor.deleteRecord(fromMainTableWithNamespace: namespace, + bundleIdentifier: bundleID, + fromSource: .fetched) + let fetched = await dbManager.databaseActor.loadMainTable( + withBundleIdentifier: bundleID, + fromSource: .fetched + ) + XCTAssertNil(fetched[namespace]?["key"]) + } + + func testDeleteAllRecordsFromMetadataTable() async throws { + try await insertMetadata() + await dbManager.databaseActor.deleteRecord(withBundleIdentifier: bundleID, namespace: namespace) + let result = await dbManager.databaseActor.loadMetadataTable(withBundleIdentifier: bundleID, + namespace: namespace) + XCTAssertTrue(result.isEmpty) + } + + func testDeleteAllRecordsFromMainTable() async throws { + try await insertMainTableValue() + + await dbManager.databaseActor.deleteAllRecords(fromTableWithSource: .fetched) + let fetched = await dbManager.databaseActor.loadMainTable( + withBundleIdentifier: bundleID, + fromSource: .fetched + ) + XCTAssertTrue(fetched.isEmpty) + } + + func testInsertExperimentTable() throws { + let exp = expectation(description: #function) + let experiment: [String: Any?] = [ + "experimentId": "experiment", + "variantId": "variant", + "triggerEvent": "fetch", + "triggerTimeoutMillis": 15000, + "timeToLiveMillis": 900_000, + "setRolloutId": "id", + "activateEvent": "activate", + "assignmentTimeoutMillis": 45000, + "clearEvent": nil, + ] + let experimentData = try JSONSerialization.data(withJSONObject: experiment, options: []) + + dbManager.insertExperimentTable(withKey: ConfigConstants.experimentTableKeyPayload, + value: experimentData) { success, _ in + XCTAssertTrue(success) + + self.dbManager.loadExperiment { success, result in + let payloads = result?[ConfigConstants.experimentTableKeyPayload] as? [Data] + XCTAssertEqual(payloads?.count, 1) + + let loadedExperiment = try! JSONSerialization.jsonObject(with: payloads![0], options: []) + as! [String: Any] + XCTAssertEqual(loadedExperiment["experimentId"] as? String, "experiment") + XCTAssertEqual(loadedExperiment["timeToLiveMillis"] as? Int, 900_000) + XCTAssertEqual(loadedExperiment["clearEvent"] as? NSNull, NSNull()) + exp.fulfill() + } + } + waitForExpectations() + } + + func testUpdateExperimentMetadata() throws { + let exp = expectation(description: #function) + let metadata = ["lastStartTime": 123] + let metadataData = try JSONSerialization.data(withJSONObject: metadata, options: []) + + dbManager.insertExperimentTable(withKey: ConfigConstants.experimentTableKeyMetadata, + value: metadataData) { success, _ in + XCTAssertTrue(success) + let newMetadata = ["lastStartTime": 456] + let newMetadataData = try! JSONSerialization.data(withJSONObject: newMetadata, options: []) + + self.dbManager.insertExperimentTable(withKey: ConfigConstants.experimentTableKeyMetadata, + value: newMetadataData) { success, _ in + XCTAssertTrue(success) + + self.dbManager.loadExperiment { success, result in + let experimentMetadata = + result?[ConfigConstants.experimentTableKeyMetadata] as? [String: Int] + XCTAssertEqual(experimentMetadata?["lastStartTime"], 456) + exp.fulfill() + } + } + } + waitForExpectations() + } + + func testLoadExperiment_noData() { + let exp = expectation(description: #function) + dbManager.loadExperiment { success, result in + XCTAssertTrue(success) // success is still true + let payloads = result?[ConfigConstants.experimentTableKeyPayload] as? [Data] + XCTAssertEqual(payloads, []) + let metadata = result?[ConfigConstants.experimentTableKeyMetadata] as? [String: Any] + XCTAssertNotNil(metadata) // metadata is initialized even if DB is empty. + exp.fulfill() + } + waitForExpectations() + } + + func testDeleteExperimentTableForKey() async throws { + try await insertExperiment() + await dbManager.databaseActor + .deleteExperimentTable(forKey: ConfigConstants.experimentTableKeyPayload) + + let result = await dbManager.databaseActor + .loadExperimentTable(fromKey: ConfigConstants.experimentTableKeyPayload) + XCTAssertEqual(result, []) + } + + func testInsertPersonalizationTable() { + let exp = expectation(description: #function) + let personalization: [String: Any] = [ + "armKey": "value", + ] + dbManager.insertOrUpdatePersonalizationConfig(personalization, fromSource: .fetched) + dbManager.loadPersonalization { success, fetchedPersonalization, _ in + XCTAssertTrue(success) + XCTAssertEqual( + fetchedPersonalization as? [String: String], + personalization as? [String: String] + ) + exp.fulfill() + } + waitForExpectations() + } + + func testLoadPersonalization_noData() { + let exp = expectation(description: #function) + dbManager.loadPersonalization { success, fetchedPersonalization, _ in + XCTAssertTrue(success) // success is still true + XCTAssertNotNil(fetchedPersonalization) // initialized even if DB is empty + exp.fulfill() + } + waitForExpectations() + } + + func testInsertRolloutTable() async { + let rolloutMetadata: [[String: any Sendable]] = [[ + "rolloutId": "id", + "variantId": "variant", + "affectedParameterKeys": ["key1", "key2"], + ]] + let success = await dbManager.databaseActor.insertOrUpdateRolloutTable( + withKey: ConfigConstants.rolloutTableKeyFetchedMetadata, + value: rolloutMetadata + ) + XCTAssertTrue(success) + let activeMetadata = + await dbManager.databaseActor.loadRolloutTable( + fromKey: ConfigConstants.rolloutTableKeyFetchedMetadata + ) + let metadata = activeMetadata[0] + let rolloutId = metadata["rolloutId"] as? String + XCTAssertEqual(rolloutId, "id") + let param2 = (metadata["affectedParameterKeys"] as? [String])?[1] + XCTAssertEqual(param2, "key2") + } + + func testLoadRolloutMetadata_noData() async { + let activeMetadata = + await dbManager.databaseActor + .loadRolloutTable(fromKey: ConfigConstants.rolloutTableKeyActiveMetadata) + XCTAssertNotNil(activeMetadata) // initialized even if DB is empty + + let fetchedMetadata = + await dbManager.databaseActor.loadRolloutTable( + fromKey: ConfigConstants.rolloutTableKeyFetchedMetadata + ) + XCTAssertNotNil(fetchedMetadata) // initialized even if DB is empty + } + + // MARK: - Helpers + + func removeDatabase() { + try? FileManager.default.removeItem(atPath: filePath) + } + + func insertMetadata() async throws { + let deviceContextData = try JSONSerialization.data(withJSONObject: [:], options: []) + let appContextData = try JSONSerialization.data(withJSONObject: [:], options: []) + let digestPerNamespaceData = try JSONSerialization.data(withJSONObject: [:], options: []) + let columnNameToValue = try [ + RCNKeyBundleIdentifier: bundleID, + RCNKeyNamespace: namespace, + RCNKeyFetchTime: Date().timeIntervalSince1970, + RCNKeyDigestPerNamespace: digestPerNamespaceData, + RCNKeyDeviceContext: deviceContextData, + RCNKeyAppContext: appContextData, + RCNKeySuccessFetchTime: JSONSerialization.data(withJSONObject: [], options: []), + RCNKeyFailureFetchTime: JSONSerialization.data(withJSONObject: [], options: []), + RCNKeyLastFetchStatus: 0, + RCNKeyLastFetchError: 0, + RCNKeyLastApplyTime: Date().timeIntervalSince1970, + RCNKeyLastSetDefaultsTime: Date().timeIntervalSince1970, + ] as [String: Any] + let success = await dbManager.databaseActor.insertMetadataTable(withValues: columnNameToValue) + XCTAssertTrue(success) + } + + private func insertMainTableValue() async throws { + let config = [ + "key1": "value1", + "key2": "value2", + ] + let data = try JSONSerialization.data(withJSONObject: config, options: []) + let rcValue = RemoteConfigValue(data: data, source: .remote) + + let success = await dbManager.databaseActor.insertMainTable( + withValues: [bundleID, namespace, "key", rcValue.dataValue], + fromSource: .fetched + ) + XCTAssertTrue(success) + } + + private func insertExperiment() async throws { + let experiment: [String: Any] = [ + "experimentId": "experiment", + ] + let experimentData = try JSONSerialization.data(withJSONObject: experiment, options: []) + let success = await dbManager.databaseActor.insertExperimentTable( + withKey: ConfigConstants.experimentTableKeyPayload, + value: experimentData + ) + XCTAssertTrue(success) + } + + private func createOrOpenDatabaseBlock() { + let semaphore = DispatchSemaphore(value: 0) + Task { + await dbManager.databaseActor.createOrOpenDatabase() + semaphore.signal() + } + semaphore.wait() + } + + private func waitForExpectations() { + waitForExpectations(timeout: 5.0) + } +}