diff --git a/Package.swift b/Package.swift index 2021e13f..31c0f0ca 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,9 @@ let package = Package( dependencies: ["FormbricksSDK"], resources: [ .process("Mock/User.json"), + .process("Mock/UserWithErrors.json"), + .process("Mock/UserWithMessages.json"), + .process("Mock/UserWithErrorsAndMessages.json"), .process("Mock/Environment.json"), ] ) diff --git a/Sources/FormbricksSDK/Formbricks.swift b/Sources/FormbricksSDK/Formbricks.swift index ac236a1b..d1a5315c 100644 --- a/Sources/FormbricksSDK/Formbricks.swift +++ b/Sources/FormbricksSDK/Formbricks.swift @@ -79,7 +79,7 @@ import Network if let attributes = config.attributes, !attributes.isEmpty { userManager?.set(attributes: attributes) } - if let language = config.attributes?["language"] { + if let language = config.attributes?["language"]?.stringValue { userManager?.set(language: language) self.language = language } @@ -96,6 +96,11 @@ import Network /** Sets the user id for the current user with the given `String`. + + - If the same userId is already set, this is a no-op. + - If a different userId is already set, the previous user state is cleaned up first + before setting the new userId. + The SDK must be initialized before calling this method. Example: @@ -110,21 +115,28 @@ import Network return } - if let existing = userManager?.userId, !existing.isEmpty { - logger?.error("A userId is already set (\"\(existing)\") – please call Formbricks.logout() before setting a new one.") + // If the same userId is already set, no-op + if let existing = userManager?.userId, existing == userId { + logger?.debug("UserId is already set to the same value, skipping") return } + // If a different userId is set, clean up the previous user state first + if let existing = userManager?.userId, !existing.isEmpty { + logger?.debug("Different userId is being set, cleaning up previous user state") + userManager?.logout() + } + userManager?.set(userId: userId) } /** - Adds an attribute for the current user with the given `String` value and `String` key. + Adds a string attribute for the current user. The SDK must be initialized before calling this method. Example: ```swift - Formbricks.setAttribute("ATTRIBUTE", forKey: "KEY") + Formbricks.setAttribute("John", forKey: "name") ``` */ @objc public static func setAttribute(_ attribute: String, forKey key: String) { @@ -134,19 +146,71 @@ import Network return } - userManager?.add(attribute: attribute, forKey: key) + userManager?.add(attribute: .string(attribute), forKey: key) + } + + /** + Adds a numeric attribute for the current user. + The SDK must be initialized before calling this method. + + Example: + ```swift + Formbricks.setAttribute(42.0, forKey: "age") + ``` + */ + public static func setAttribute(_ attribute: Double, forKey key: String) { + guard Formbricks.isInitialized else { + let error = FormbricksSDKError(type: .sdkIsNotInitialized) + Formbricks.logger?.error(error.message) + return + } + + userManager?.add(attribute: .number(attribute), forKey: key) + } + + /** + Adds a date attribute for the current user. + The date is converted to an ISO 8601 string. The backend will detect the format and treat it as a date type. + The SDK must be initialized before calling this method. + + Example: + ```swift + Formbricks.setAttribute(Date(), forKey: "signupDate") + ``` + */ + public static func setAttribute(_ attribute: Date, forKey key: String) { + guard Formbricks.isInitialized else { + let error = FormbricksSDKError(type: .sdkIsNotInitialized) + Formbricks.logger?.error(error.message) + return + } + + userManager?.add(attribute: .string(ISO8601DateFormatter().string(from: attribute)), forKey: key) } /** - Sets the user attributes for the current user with the given `Dictionary` of `String` values and `String` keys. + Sets the user attributes for the current user. + + Attribute types are determined by the value: + - String values -> string attribute + - Number values -> number attribute + - Use ISO 8601 date strings for date attributes + + On first write to a new attribute, the type is set based on the value type. + On subsequent writes, the value must match the existing attribute type. + The SDK must be initialized before calling this method. Example: ```swift - Formbricks.setAttributes(["KEY", "ATTRIBUTE"]) + Formbricks.setAttributes([ + "name": "John", + "age": 30, + "score": 9.5 + ]) ``` */ - @objc public static func setAttributes(_ attributes: [String : String]) { + public static func setAttributes(_ attributes: [String : AttributeValue]) { guard Formbricks.isInitialized else { let error = FormbricksSDKError(type: .sdkIsNotInitialized) Formbricks.logger?.error(error.message) diff --git a/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift b/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift index 5159e9c8..28df5356 100644 --- a/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift +++ b/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift @@ -5,12 +5,12 @@ import Foundation let appUrl: String let environmentId: String let userId: String? - let attributes: [String:String]? + let attributes: [String: AttributeValue]? let logLevel: LogLevel /// Optional custom service, injected via Builder let customService: FormbricksServiceProtocol? - init(appUrl: String, environmentId: String, userId: String?, attributes: [String : String]?, logLevel: LogLevel, customService: FormbricksServiceProtocol?) { + init(appUrl: String, environmentId: String, userId: String?, attributes: [String: AttributeValue]?, logLevel: LogLevel, customService: FormbricksServiceProtocol?) { self.appUrl = appUrl self.environmentId = environmentId self.userId = userId @@ -24,7 +24,7 @@ import Foundation var appUrl: String var environmentId: String var userId: String? - var attributes: [String:String] = [:] + var attributes: [String: AttributeValue] = [:] var logLevel: LogLevel = .error /// Optional custom service, injected via Builder var customService: FormbricksServiceProtocol? @@ -41,14 +41,27 @@ import Foundation } /// Sets the attributes for the Builder object. - @objc public func set(attributes: [String:String]) -> Builder { + /// + /// Thanks to `ExpressibleByStringLiteral`, `ExpressibleByIntegerLiteral`, + /// and `ExpressibleByFloatLiteral` conformances on `AttributeValue`, + /// you can use literal syntax: + /// ```swift + /// .set(attributes: ["name": "John", "age": 30]) + /// ``` + public func set(attributes: [String: AttributeValue]) -> Builder { self.attributes = attributes return self } + + /// Sets the attributes for the Builder object using string values (Obj-C compatible). + @objc public func set(stringAttributes: [String: String]) -> Builder { + self.attributes = stringAttributes.mapValues { .string($0) } + return self + } - /// Adds an attribute to the Builder object. + /// Adds a string attribute to the Builder object (Obj-C compatible). @objc public func add(attribute: String, forKey key: String) -> Builder { - self.attributes[key] = attribute + self.attributes[key] = .string(attribute) return self } diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index b60a5daa..b01be37a 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -83,7 +83,7 @@ final class SurveyManager { let actionClasses = environmentResponse?.data.data.actionClasses ?? [] let codeActionClasses = actionClasses.filter { $0.type == "code" } guard let actionClass = codeActionClasses.first(where: { $0.key == action }) else { - Formbricks.logger?.error("\(action) action unknown. Please add this action in Formbricks first in order to use it in your code.") + Formbricks.logger?.error("Action with identifier '\(action)' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking.") return } diff --git a/Sources/FormbricksSDK/Manager/UserManager.swift b/Sources/FormbricksSDK/Manager/UserManager.swift index e523a7b3..43e49ef9 100644 --- a/Sources/FormbricksSDK/Manager/UserManager.swift +++ b/Sources/FormbricksSDK/Manager/UserManager.swift @@ -40,12 +40,12 @@ final class UserManager: UserManagerSyncable { } /// Starts an update queue with the given attribute. - func add(attribute: String, forKey key: String) { + func add(attribute: AttributeValue, forKey key: String) { updateQueue?.add(attribute: attribute, forKey: key) } /// Starts an update queue with the given attributes. - func set(attributes: [String: String]) { + func set(attributes: [String: AttributeValue]) { updateQueue?.set(attributes: attributes) } @@ -85,7 +85,7 @@ final class UserManager: UserManagerSyncable { } /// Syncs the user state with the server, calls the `self?.surveyManager?.filterSurveys()` method and starts the sync timer. - func syncUser(withId id: String, attributes: [String: String]? = nil) { + func syncUser(withId id: String, attributes: [String: AttributeValue]? = nil) { service.postUser(id: id, attributes: attributes) { [weak self] result in switch result { case .success(let userResponse): @@ -100,6 +100,20 @@ final class UserManager: UserManagerSyncable { let serverLanguage = userResponse.data.state?.data?.language Formbricks.language = serverLanguage ?? "default" + // Log errors (always visible) - e.g., invalid attribute keys, type mismatches + if let errors = userResponse.data.errors { + for error in errors { + Formbricks.logger?.error(error) + } + } + + // Log informational messages (debug only) + if let messages = userResponse.data.messages { + for message in messages { + Formbricks.logger?.debug("User update message: \(message)") + } + } + self?.updateQueue?.reset() self?.surveyManager?.filterSurveys() self?.startSyncTimer() @@ -111,13 +125,7 @@ final class UserManager: UserManagerSyncable { /// Logs out the user and clears the user state. func logout() { - var isUserIdDefined = false - - if userId != nil { - isUserIdDefined = true - } else { - Formbricks.logger?.error("no userId is set, please set a userId first using the setUserId function") - } + Formbricks.logger?.debug("Logging out and cleaning user state") UserDefaults.standard.removeObject(forKey: UserManager.userIdKey) UserDefaults.standard.removeObject(forKey: UserManager.contactIdKey) @@ -141,11 +149,6 @@ final class UserManager: UserManagerSyncable { // Re-filter surveys for logged out user surveyManager?.filterSurveys() - - if isUserIdDefined { - Formbricks.logger?.debug("Successfully logged out user and reset the user state.") - } - } func cleanupUpdateQueue() { diff --git a/Sources/FormbricksSDK/Model/User/AttributeValue.swift b/Sources/FormbricksSDK/Model/User/AttributeValue.swift new file mode 100644 index 00000000..ae33e0ae --- /dev/null +++ b/Sources/FormbricksSDK/Model/User/AttributeValue.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Represents a user attribute value that can be a string, number, or date. +/// +/// Attribute types are determined by the Swift value type: +/// - String values -> string attribute +/// - Number values -> number attribute +/// - Date values -> date attribute (converted to ISO string) +/// +/// On first write to a new attribute, the type is set based on the value type. +/// On subsequent writes, the value must match the existing attribute type. +/// +/// Supports literal syntax in dictionaries: +/// ```swift +/// let attributes: [String: AttributeValue] = [ +/// "name": "John", +/// "age": 30, +/// "score": 9.5 +/// ] +/// ``` +public enum AttributeValue: Codable, Equatable { + case string(String) + case number(Double) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let doubleValue = try? container.decode(Double.self) { + self = .number(doubleValue) + } else if let stringValue = try? container.decode(String.self) { + self = .string(stringValue) + } else { + throw DecodingError.typeMismatch( + AttributeValue.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected String or Number for AttributeValue" + ) + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + } + } + + /// The string representation of this attribute value, if it is a string. + public var stringValue: String? { + if case .string(let value) = self { + return value + } + return nil + } + + /// The numeric representation of this attribute value, if it is a number. + public var numberValue: Double? { + if case .number(let value) = self { + return value + } + return nil + } +} + +// MARK: - Literal conformances for ergonomic dictionary syntax + +extension AttributeValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension AttributeValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension AttributeValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} diff --git a/Sources/FormbricksSDK/Model/User/UserResponseData.swift b/Sources/FormbricksSDK/Model/User/UserResponseData.swift index 98a8bffe..30c9b12c 100644 --- a/Sources/FormbricksSDK/Model/User/UserResponseData.swift +++ b/Sources/FormbricksSDK/Model/User/UserResponseData.swift @@ -1,3 +1,5 @@ struct UserResponseData: Codable { let state: UserState? + let messages: [String]? + let errors: [String]? } diff --git a/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift b/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift index 398f525d..41078ed8 100644 --- a/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift +++ b/Sources/FormbricksSDK/Networking/ClientAPI/Endpoints/User/PostUserRequest.swift @@ -9,11 +9,11 @@ final class PostUserRequest: EncodableRequest, CodableRequ struct Body: Codable { let userId: String - let attributes: [String: String]? + let attributes: [String: AttributeValue]? } - init(userId: String, attributes: [String: String]?) { + init(userId: String, attributes: [String: AttributeValue]?) { super.init(object: Body(userId: userId, attributes: attributes)) } } diff --git a/Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift b/Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift index 79d0a597..f7b10e86 100644 --- a/Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift +++ b/Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift @@ -1,7 +1,7 @@ import Foundation protocol UserManagerSyncable: AnyObject { - func syncUser(withId id: String, attributes: [String: String]?) + func syncUser(withId id: String, attributes: [String: AttributeValue]?) } /// Update queue. This class is used to queue updates to the user. @@ -12,7 +12,7 @@ final class UpdateQueue { private let syncQueue = DispatchQueue(label: "com.formbricks.updateQueue") private var userId: String? - private var attributes: [String : String]? + private var attributes: [String : AttributeValue]? private var language: String? private var timer: Timer? @@ -29,14 +29,14 @@ final class UpdateQueue { } } - func set(attributes: [String : String]) { + func set(attributes: [String : AttributeValue]) { syncQueue.sync { self.attributes = attributes startDebounceTimer() } } - func add(attribute: String, forKey key: String) { + func add(attribute: AttributeValue, forKey key: String) { syncQueue.sync { if var attr = self.attributes { attr[key] = attribute @@ -57,7 +57,7 @@ final class UpdateQueue { if effectiveUserId != nil { // If we have a userId, set attributes - self.attributes = ["language": language] + self.attributes = ["language": .string(language)] } else { // If no userId, just update locally without API call Formbricks.logger?.debug("UpdateQueue - updating language locally: \(language)") @@ -97,7 +97,7 @@ private extension UpdateQueue { @objc func commit() { var effectiveUserId: String? - var effectiveAttributes: [String: String]? + var effectiveAttributes: [String: AttributeValue]? // Capture a consistent snapshot under the sync queue syncQueue.sync { diff --git a/Sources/FormbricksSDK/Networking/Service/FormbricksService.swift b/Sources/FormbricksSDK/Networking/Service/FormbricksService.swift index f7f6ccd0..3d45f4c4 100644 --- a/Sources/FormbricksSDK/Networking/Service/FormbricksService.swift +++ b/Sources/FormbricksSDK/Networking/Service/FormbricksService.swift @@ -10,7 +10,7 @@ class FormbricksService: FormbricksServiceProtocol { // MARK: - User - /// Logs in a user with the given ID or creates one if it doesn't exist. - func postUser(id: String, attributes: [String: String]?, completion: @escaping (ResultType) -> Void) { + func postUser(id: String, attributes: [String: AttributeValue]?, completion: @escaping (ResultType) -> Void) { let endPointRequest = PostUserRequest(userId: id, attributes: attributes) execute(endPointRequest, withCompletion: completion) } @@ -22,7 +22,7 @@ protocol FormbricksServiceProtocol { ) func postUser( id: String, - attributes: [String: String]?, + attributes: [String: AttributeValue]?, completion: @escaping (ResultType) -> Void ) } diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index e38e3795..65a3e8b9 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -261,6 +261,219 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertNil(manager.getLanguageCode(survey: survey, language: "spanish")) } + // MARK: - UserManager syncUser errors/messages tests + + func testSyncUserLogsErrors() { + let errorsMockService = MockFormbricksService() + errorsMockService.userMockResponse = .userWithErrors + + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(errorsMockService) + .build() + Formbricks.setup(with: config) + + // Refresh environment first + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let envExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } + wait(for: [envExpectation]) + + // Set userId to trigger syncUser which uses the mock with errors + Formbricks.setUserId(userId) + + let syncExpectation = expectation(description: "User synced with errors") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + syncExpectation.fulfill() + } + wait(for: [syncExpectation], timeout: 3.0) + + // Verify the user was still synced successfully despite errors + XCTAssertEqual(Formbricks.userManager?.userId, userId, "User ID should be set even when response has errors") + XCTAssertNotNil(Formbricks.userManager?.syncTimer, "Sync timer should still be set") + } + + func testSyncUserLogsMessages() { + let messagesMockService = MockFormbricksService() + messagesMockService.userMockResponse = .userWithMessages + + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(messagesMockService) + .build() + Formbricks.setup(with: config) + + // Refresh environment first + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let envExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } + wait(for: [envExpectation]) + + // Set userId to trigger syncUser which uses the mock with messages + Formbricks.setUserId(userId) + + let syncExpectation = expectation(description: "User synced with messages") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + syncExpectation.fulfill() + } + wait(for: [syncExpectation], timeout: 3.0) + + // Verify the user was synced successfully + XCTAssertEqual(Formbricks.userManager?.userId, userId, "User ID should be set when response has messages") + XCTAssertNotNil(Formbricks.userManager?.syncTimer, "Sync timer should still be set") + } + + func testSyncUserLogsErrorsAndMessages() { + let bothMockService = MockFormbricksService() + bothMockService.userMockResponse = .userWithErrorsAndMessages + + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(bothMockService) + .build() + Formbricks.setup(with: config) + + // Refresh environment first + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let envExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } + wait(for: [envExpectation]) + + // Set userId to trigger syncUser which uses the mock with both errors and messages + Formbricks.setUserId(userId) + + let syncExpectation = expectation(description: "User synced with errors and messages") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { + syncExpectation.fulfill() + } + wait(for: [syncExpectation], timeout: 3.0) + + // Verify the user was synced successfully despite having both errors and messages + XCTAssertEqual(Formbricks.userManager?.userId, userId, "User ID should be set when response has both errors and messages") + XCTAssertNotNil(Formbricks.userManager?.syncTimer, "Sync timer should still be set") + } + + // MARK: - setUserId override behavior tests + + func testSetUserIdSameValueIsNoOp() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + // Refresh environment first + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let envExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } + wait(for: [envExpectation]) + + // Set userId + Formbricks.setUserId(userId) + let setExpectation = expectation(description: "User set") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { setExpectation.fulfill() } + wait(for: [setExpectation], timeout: 3.0) + + XCTAssertEqual(Formbricks.userManager?.userId, userId) + + // Set the same userId again — should be a no-op, userId stays the same + Formbricks.setUserId(userId) + XCTAssertEqual(Formbricks.userManager?.userId, userId, "Same userId should remain set (no-op)") + } + + func testSetUserIdDifferentValueOverridesPrevious() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + // Refresh environment first + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let envExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } + wait(for: [envExpectation]) + + // Set initial userId and wait for sync to complete + Formbricks.setUserId(userId) + let setExpectation = expectation(description: "First user set") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { setExpectation.fulfill() } + wait(for: [setExpectation], timeout: 3.0) + + XCTAssertEqual(Formbricks.userManager?.userId, userId) + + // Capture previous state to verify cleanup happens + Formbricks.surveyManager?.onNewDisplay(surveyId: surveyID) + XCTAssertEqual(Formbricks.userManager?.displays?.count, 1, "Should have 1 display before override") + + // Set a different userId — should clean up previous user state first + let newUserId = "NEW-USER-ID-12345" + Formbricks.setUserId(newUserId) + + // Immediately after setUserId, the previous user state should be cleaned up + // (logout was called synchronously before queueing the new userId) + XCTAssertNil(Formbricks.userManager?.userId, "Previous userId should be cleared by logout") + XCTAssertNil(Formbricks.userManager?.displays, "Previous displays should be cleared by logout") + XCTAssertNil(Formbricks.userManager?.responses, "Previous responses should be cleared by logout") + XCTAssertNil(Formbricks.userManager?.segments, "Previous segments should be cleared by logout") + } + + func testLogoutWithoutUserIdDoesNotError() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + // Logout without ever setting a userId — should not crash or error + XCTAssertNil(Formbricks.userManager?.userId) + Formbricks.logout() + XCTAssertNil(Formbricks.userManager?.userId, "userId should remain nil after logout") + } + + // MARK: - setAttribute overload tests + + func testSetAttributeDouble() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + // Should not crash; exercises the Double overload + Formbricks.setAttribute(42.0, forKey: "age") + } + + func testSetAttributeDate() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + // Should not crash; exercises the Date overload + Formbricks.setAttribute(Date(), forKey: "signupDate") + } + + // MARK: - ConfigBuilder coverage tests + + func testConfigBuilderStringAttributes() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .set(stringAttributes: ["key1": "val1", "key2": "val2"]) + .build() + + XCTAssertEqual(config.attributes?["key1"], "val1") + XCTAssertEqual(config.attributes?["key2"], "val2") + } + + func testConfigBuilderAddAttribute() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .add(attribute: "hello", forKey: "greeting") + .build() + + XCTAssertEqual(config.attributes?["greeting"], "hello") + } + // MARK: - PresentSurveyManager tests func testPresentCompletesInHeadlessEnvironment() { diff --git a/Tests/FormbricksSDKTests/Mock/UserWithErrors.json b/Tests/FormbricksSDKTests/Mock/UserWithErrors.json new file mode 100644 index 00000000..a32a7aa7 --- /dev/null +++ b/Tests/FormbricksSDKTests/Mock/UserWithErrors.json @@ -0,0 +1,19 @@ +{ + "data": { + "state": { + "data": { + "contactId": "cm6ovw6jl000hsf0knn547xyz", + "displays": [], + "lastDisplayAt": null, + "responses": [], + "segments": ["cm6ovw6jl000hsf0knn547w0y"], + "userId": "6CCCE716-6783-4D0F-8344-9C7DFA43D8F7" + }, + "expiresAt": "2035-03-06T10:59:32.359Z" + }, + "errors": [ + "Attribute 'invalidKey' does not exist", + "Type mismatch for attribute 'age': expected number, got string" + ] + } +} diff --git a/Tests/FormbricksSDKTests/Mock/UserWithErrorsAndMessages.json b/Tests/FormbricksSDKTests/Mock/UserWithErrorsAndMessages.json new file mode 100644 index 00000000..f21fd3ce --- /dev/null +++ b/Tests/FormbricksSDKTests/Mock/UserWithErrorsAndMessages.json @@ -0,0 +1,21 @@ +{ + "data": { + "state": { + "data": { + "contactId": "cm6ovw6jl000hsf0knn547xyz", + "displays": [], + "lastDisplayAt": null, + "responses": [], + "segments": ["cm6ovw6jl000hsf0knn547w0y"], + "userId": "6CCCE716-6783-4D0F-8344-9C7DFA43D8F7" + }, + "expiresAt": "2035-03-06T10:59:32.359Z" + }, + "messages": [ + "Attribute 'email' already exists for this contact" + ], + "errors": [ + "Attribute 'invalidKey' does not exist" + ] + } +} diff --git a/Tests/FormbricksSDKTests/Mock/UserWithMessages.json b/Tests/FormbricksSDKTests/Mock/UserWithMessages.json new file mode 100644 index 00000000..d34957b7 --- /dev/null +++ b/Tests/FormbricksSDKTests/Mock/UserWithMessages.json @@ -0,0 +1,19 @@ +{ + "data": { + "state": { + "data": { + "contactId": "cm6ovw6jl000hsf0knn547xyz", + "displays": [], + "lastDisplayAt": null, + "responses": [], + "segments": ["cm6ovw6jl000hsf0knn547w0y"], + "userId": "6CCCE716-6783-4D0F-8344-9C7DFA43D8F7" + }, + "expiresAt": "2035-03-06T10:59:32.359Z" + }, + "messages": [ + "Attribute 'email' already exists for this contact", + "Contact successfully updated" + ] + } +} diff --git a/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift b/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift index ad23504b..0a560dda 100644 --- a/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift +++ b/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift @@ -5,11 +5,17 @@ import UIKit enum MockResponse: String { case environment = "Environment" case user = "User" + case userWithErrors = "UserWithErrors" + case userWithMessages = "UserWithMessages" + case userWithErrorsAndMessages = "UserWithErrorsAndMessages" } class MockFormbricksService: FormbricksService { var isErrorResponseNeeded = false + /// Controls which mock JSON file is used for postUser responses. + /// Defaults to `.user` (the standard User.json). + var userMockResponse: MockResponse = .user override func getEnvironmentState(completion: @escaping (ResultType) -> Void) { if isErrorResponseNeeded { @@ -19,11 +25,11 @@ class MockFormbricksService: FormbricksService { } } - override func postUser(id: String, attributes: [String : String]?, completion: @escaping (ResultType) -> Void) { + override func postUser(id: String, attributes: [String : AttributeValue]?, completion: @escaping (ResultType) -> Void) { if isErrorResponseNeeded { completion(.failure(RuntimeError(message: ""))) } else { - execute(.user, completion: completion) + execute(userMockResponse, completion: completion) } } diff --git a/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift b/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift index 65dd30c3..c22f16a8 100644 --- a/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift +++ b/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift @@ -3,9 +3,9 @@ import XCTest class MockUserManager: UserManagerSyncable { var lastSyncedUserId: String? - var lastSyncedAttributes: [String: String]? + var lastSyncedAttributes: [String: AttributeValue]? var syncCallCount = 0 - func syncUser(withId id: String, attributes: [String : String]?) { + func syncUser(withId id: String, attributes: [String : AttributeValue]?) { lastSyncedUserId = id lastSyncedAttributes = attributes syncCallCount += 1 @@ -73,6 +73,17 @@ final class UpdateQueueTests: XCTestCase { } wait(for: [exp], timeout: 1.0) } + + func testAddNumberAttribute() { + let exp = expectation(description: "Add number attribute") + queue.set(userId: "user123") + queue.add(attribute: 42.0, forKey: "age") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["age"], 42.0) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + } func testSetLanguageWithUserId() { let exp = expectation(description: "Set language with userId triggers commit") @@ -133,4 +144,4 @@ final class UpdateQueueTests: XCTestCase { } wait(for: [exp], timeout: 1.0) } -} \ No newline at end of file +}