From 9a51b13f8da6e4daf173bfbee2c11bf5649ce9ad Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 10 Feb 2026 16:15:44 +0530 Subject: [PATCH 01/10] adds click outside close and overlay settings --- .../Manager/PresentSurveyManager.swift | 18 +++++++++++++++--- .../FormbricksSDK/Manager/SurveyManager.swift | 17 ++++++++++++++--- .../Model/Environment/Project/Project.swift | 2 +- .../Model/Environment/Survey.swift | 9 ++++++++- .../WebView/FormbricksViewModel.swift | 3 ++- .../FormbricksSDKTests.swift | 6 ++++-- Tests/FormbricksSDKTests/Mock/Environment.json | 4 ++-- 7 files changed, 46 insertions(+), 13 deletions(-) diff --git a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift index b92c79ab..5c8fa750 100644 --- a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift @@ -13,14 +13,14 @@ final class PresentSurveyManager { private weak var viewController: UIViewController? /// Present the webview - func present(environmentResponse: EnvironmentResponse, id: String, completion: ((Bool) -> Void)? = nil) { + func present(environmentResponse: EnvironmentResponse, id: String, overlay: SurveyOverlay = .none, completion: ((Bool) -> Void)? = nil) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if let window = UIApplication.safeKeyWindow { - let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id)) + let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id)) let vc = UIHostingController(rootView: view) vc.modalPresentationStyle = .overCurrentContext - vc.view.backgroundColor = UIColor.gray.withAlphaComponent(0.6) + vc.view.backgroundColor = Self.backgroundColor(for: overlay) if let presentationController = vc.presentationController as? UISheetPresentationController { presentationController.detents = [.large()] } @@ -34,6 +34,18 @@ final class PresentSurveyManager { } } + /// Returns the appropriate background color for the given overlay style. + private static func backgroundColor(for overlay: SurveyOverlay) -> UIColor { + switch overlay { + case .dark: + return UIColor(white: 0.2, alpha: 0.6) + case .light: + return UIColor(white: 0.6, alpha: 0.4) + case .none: + return .clear + } + } + /// Dismiss the webview func dismissView() { viewController?.dismiss(animated: true) diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index fa2f0c90..c51509ee 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -120,7 +120,8 @@ final class SurveyManager { DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in guard let self = self else { return } if let environmentResponse = self.environmentResponse { - self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id) { success in + let overlay = self.resolveOverlay(for: survey) + self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id, overlay: overlay) { success in if !success { self.isShowingSurvey = false } @@ -189,9 +190,10 @@ private extension SurveyManager { /// The view controller is presented over the current context. func showSurvey(withId id: String) { if let environmentResponse = environmentResponse { - presentSurveyManager.present(environmentResponse: environmentResponse, id: id) + let survey = environmentResponse.data.data.surveys?.first(where: { $0.id == id }) + let overlay = resolveOverlay(for: survey) + presentSurveyManager.present(environmentResponse: environmentResponse, id: id, overlay: overlay) } - } /// Starts a timer to refresh the environment state after the given timeout (`expiresAt`). @@ -345,6 +347,15 @@ extension SurveyManager { return entry.language.code } + /// Resolves the overlay style for the given survey, falling back to the project-level default. + /// Survey-level `projectOverwrites.overlay` takes precedence over `project.overlay`. + func resolveOverlay(for survey: Survey?) -> SurveyOverlay { + if let surveyOverlay = survey?.projectOverwrites?.overlay { + return surveyOverlay + } + return environmentResponse?.data.data.project.overlay ?? .none + } + /// Filters the surveys based on the user's segments. func filterSurveysBasedOnSegments(_ surveys: [Survey], segments: [String]) -> [Survey] { return surveys.filter { survey in diff --git a/Sources/FormbricksSDK/Model/Environment/Project/Project.swift b/Sources/FormbricksSDK/Model/Environment/Project/Project.swift index cb89287d..cd92c094 100644 --- a/Sources/FormbricksSDK/Model/Environment/Project/Project.swift +++ b/Sources/FormbricksSDK/Model/Environment/Project/Project.swift @@ -2,7 +2,7 @@ struct Project: Codable { let id: String? let recontactDays: Int? let clickOutsideClose: Bool? - let darkOverlay: Bool? + let overlay: SurveyOverlay? let placement: String? let inAppSurveyBranding: Bool? let styling: Styling? diff --git a/Sources/FormbricksSDK/Model/Environment/Survey.swift b/Sources/FormbricksSDK/Model/Environment/Survey.swift index 37b2c25c..3544a8d5 100644 --- a/Sources/FormbricksSDK/Model/Environment/Survey.swift +++ b/Sources/FormbricksSDK/Model/Environment/Survey.swift @@ -35,12 +35,19 @@ enum Placement: String, Codable { case center = "center" } +/// Defines the overlay style displayed behind a survey modal. +enum SurveyOverlay: String, Codable { + case none = "none" + case light = "light" + case dark = "dark" +} + struct ProjectOverwrites: Codable { let brandColor: String? let highlightBorderColor: String? let placement: Placement? let clickOutsideClose: Bool? - let darkOverlay: Bool? + let overlay: SurveyOverlay? } struct Survey: Codable { diff --git a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift index 67541a1c..439c78b5 100644 --- a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift +++ b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift @@ -107,7 +107,8 @@ private class WebViewData { data["placement"] = project.placement } - data["darkOverlay"] = matchedSurvey?.projectOverwrites?.darkOverlay ?? project.darkOverlay + data["clickOutside"] = matchedSurvey?.projectOverwrites?.clickOutsideClose ?? project.clickOutsideClose ?? false + data["overlay"] = (matchedSurvey?.projectOverwrites?.overlay ?? project.overlay ?? .none).rawValue let isMultiLangSurvey = (matchedSurvey?.languages?.count ?? 0) > 1 diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index b24d1356..1f27358b 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -305,8 +305,10 @@ final class FormbricksSDKTests: XCTestCase { return } - // placement should come from survey.projectOverwrites (center), and darkOverlay true + // placement should come from survey.projectOverwrites (center), overlay should be "dark", + // and clickOutside should be false (from survey.projectOverwrites.clickOutsideClose) XCTAssertEqual(object["placement"] as? String, "center") - XCTAssertEqual(object["darkOverlay"] as? Bool, true) + XCTAssertEqual(object["overlay"] as? String, "dark") + XCTAssertEqual(object["clickOutside"] as? Bool, false) } } diff --git a/Tests/FormbricksSDKTests/Mock/Environment.json b/Tests/FormbricksSDKTests/Mock/Environment.json index 8e900f3d..fa8ec1dc 100644 --- a/Tests/FormbricksSDKTests/Mock/Environment.json +++ b/Tests/FormbricksSDKTests/Mock/Environment.json @@ -12,7 +12,7 @@ ], "project": { "clickOutsideClose": true, - "darkOverlay": false, + "overlay": "none", "id": "cm6ovvfnv0003sf0k7zi8r3ac", "inAppSurveyBranding": true, "placement": "bottomRight", @@ -57,7 +57,7 @@ "name": "Start from scratch", "projectOverwrites": { "placement": "center", - "darkOverlay": true, + "overlay": "dark", "clickOutsideClose": false, "brandColor": "#ff0000", "highlightBorderColor": "#00ff00" From 627f62fcc0f6a216b7a78c0df90af845c70e46e9 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 11 Feb 2026 14:31:57 +0530 Subject: [PATCH 02/10] fix: test run action --- .github/workflows/sonarqube.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index c280fa46..c56c9da2 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -34,7 +34,7 @@ jobs: -scheme 'FormbricksSDK' \ -sdk iphonesimulator \ -config Debug \ - -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.4' \ + -destination 'platform=iOS Simulator,name=iPhone SE (3rd generation),OS=18.5' \ -derivedDataPath build \ -enableCodeCoverage YES From 2a3931bde6cf0e468cf9a5536112697586af1ab7 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 11 Feb 2026 16:16:20 +0530 Subject: [PATCH 03/10] tests --- .../Manager/PresentSurveyManager.swift | 2 +- .../FormbricksSDKTests.swift | 142 ++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) diff --git a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift index 5c8fa750..d63a085e 100644 --- a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift @@ -35,7 +35,7 @@ final class PresentSurveyManager { } /// Returns the appropriate background color for the given overlay style. - private static func backgroundColor(for overlay: SurveyOverlay) -> UIColor { + static func backgroundColor(for overlay: SurveyOverlay) -> UIColor { switch overlay { case .dark: return UIColor(white: 0.2, alpha: 0.6) diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index 1f27358b..2e055a3c 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -261,6 +261,148 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertNil(manager.getLanguageCode(survey: survey, language: "spanish")) } + // MARK: - PresentSurveyManager overlay background color tests + + func testBackgroundColorForDarkOverlay() { + let color = PresentSurveyManager.backgroundColor(for: .dark) + var white: CGFloat = 0 + var alpha: CGFloat = 0 + color.getWhite(&white, alpha: &alpha) + XCTAssertEqual(white, 0.2, accuracy: 0.01, "Dark overlay should use 0.2 white") + XCTAssertEqual(alpha, 0.6, accuracy: 0.01, "Dark overlay should use 0.6 alpha") + } + + func testBackgroundColorForLightOverlay() { + let color = PresentSurveyManager.backgroundColor(for: .light) + var white: CGFloat = 0 + var alpha: CGFloat = 0 + color.getWhite(&white, alpha: &alpha) + XCTAssertEqual(white, 0.6, accuracy: 0.01, "Light overlay should use 0.6 white") + XCTAssertEqual(alpha, 0.4, accuracy: 0.01, "Light overlay should use 0.4 alpha") + } + + func testBackgroundColorForNoneOverlay() { + let color = PresentSurveyManager.backgroundColor(for: .none) + XCTAssertEqual(color, .clear, "None overlay should return clear color") + } + + func testPresentWithOverlayCompletesInHeadlessEnvironment() { + // In a headless test environment there is no key window, so present() should + // call the completion with false for every overlay variant. + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let loadExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } + wait(for: [loadExpectation]) + + guard let env = Formbricks.surveyManager?.environmentResponse else { + XCTFail("Missing environmentResponse") + return + } + + let manager = PresentSurveyManager() + + for overlay in [SurveyOverlay.none, .light, .dark] { + let presentExpectation = expectation(description: "Present with \(overlay.rawValue) overlay") + manager.present(environmentResponse: env, id: surveyID, overlay: overlay) { success in + // No key window in headless tests → completion(false) + XCTAssertFalse(success, "Presentation should fail in headless environment for overlay: \(overlay.rawValue)") + presentExpectation.fulfill() + } + wait(for: [presentExpectation], timeout: 2.0) + } + } + + // MARK: - SurveyManager.resolveOverlay tests + + func testResolveOverlayUsesSurveyOverwrite() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let loadExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } + wait(for: [loadExpectation]) + + guard let manager = Formbricks.surveyManager else { + XCTFail("Missing surveyManager") + return + } + + // The mock survey has projectOverwrites.overlay = "dark" + let surveyWithOverwrite = manager.environmentResponse?.data.data.surveys?.first(where: { $0.id == surveyID }) + XCTAssertNotNil(surveyWithOverwrite) + XCTAssertEqual(manager.resolveOverlay(for: surveyWithOverwrite), .dark, + "Should use survey-level projectOverwrites overlay") + } + + func testResolveOverlayFallsBackToProjectDefault() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let loadExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } + wait(for: [loadExpectation]) + + guard let manager = Formbricks.surveyManager else { + XCTFail("Missing surveyManager") + return + } + + // A survey without projectOverwrites should fall back to project.overlay ("none" in mock) + let surveyWithoutOverwrite = Survey( + id: "no-overwrite", + name: "No Overwrite", + triggers: nil, + recontactDays: nil, + displayLimit: nil, + delay: nil, + displayPercentage: nil, + displayOption: .respondMultiple, + segment: nil, + styling: nil, + languages: nil, + projectOverwrites: nil + ) + XCTAssertEqual(manager.resolveOverlay(for: surveyWithoutOverwrite), .none, + "Should fall back to project-level overlay when no survey overwrite exists") + } + + func testResolveOverlayReturnsNoneForNilSurvey() { + let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) + .setLogLevel(.debug) + .service(mockService) + .build() + Formbricks.setup(with: config) + + Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) + let loadExpectation = expectation(description: "Env loaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } + wait(for: [loadExpectation]) + + guard let manager = Formbricks.surveyManager else { + XCTFail("Missing surveyManager") + return + } + + XCTAssertEqual(manager.resolveOverlay(for: nil), .none, + "Should return .none when survey is nil") + } + + // MARK: - WebView data tests + func testWebViewDataUsesSurveyOverwrites() { // Setup SDK with mock service loading Environment.json (which now includes projectOverwrites) let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) From 6dd7e7c0b89489663302b2c97d37dfd25f1d7dbe Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 11 Feb 2026 17:36:05 +0530 Subject: [PATCH 04/10] removes native overlay --- .../Manager/PresentSurveyManager.swift | 18 +-- .../FormbricksSDK/Manager/SurveyManager.swift | 16 +-- .../FormbricksSDKTests.swift | 127 ++---------------- 3 files changed, 15 insertions(+), 146 deletions(-) diff --git a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift index d63a085e..5875cf84 100644 --- a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift @@ -13,14 +13,16 @@ final class PresentSurveyManager { private weak var viewController: UIViewController? /// Present the webview - func present(environmentResponse: EnvironmentResponse, id: String, overlay: SurveyOverlay = .none, completion: ((Bool) -> Void)? = nil) { + /// The native background is always `.clear` — overlay rendering is handled + /// entirely by the JS survey library inside the WebView to avoid double-overlay artifacts. + func present(environmentResponse: EnvironmentResponse, id: String, completion: ((Bool) -> Void)? = nil) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if let window = UIApplication.safeKeyWindow { let view = FormbricksView(viewModel: FormbricksViewModel(environmentResponse: environmentResponse, surveyId: id)) let vc = UIHostingController(rootView: view) vc.modalPresentationStyle = .overCurrentContext - vc.view.backgroundColor = Self.backgroundColor(for: overlay) + vc.view.backgroundColor = .clear if let presentationController = vc.presentationController as? UISheetPresentationController { presentationController.detents = [.large()] } @@ -34,18 +36,6 @@ final class PresentSurveyManager { } } - /// Returns the appropriate background color for the given overlay style. - static func backgroundColor(for overlay: SurveyOverlay) -> UIColor { - switch overlay { - case .dark: - return UIColor(white: 0.2, alpha: 0.6) - case .light: - return UIColor(white: 0.6, alpha: 0.4) - case .none: - return .clear - } - } - /// Dismiss the webview func dismissView() { viewController?.dismiss(animated: true) diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index c51509ee..b60a5daa 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -120,8 +120,7 @@ final class SurveyManager { DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in guard let self = self else { return } if let environmentResponse = self.environmentResponse { - let overlay = self.resolveOverlay(for: survey) - self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id, overlay: overlay) { success in + self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id) { success in if !success { self.isShowingSurvey = false } @@ -190,9 +189,7 @@ private extension SurveyManager { /// The view controller is presented over the current context. func showSurvey(withId id: String) { if let environmentResponse = environmentResponse { - let survey = environmentResponse.data.data.surveys?.first(where: { $0.id == id }) - let overlay = resolveOverlay(for: survey) - presentSurveyManager.present(environmentResponse: environmentResponse, id: id, overlay: overlay) + presentSurveyManager.present(environmentResponse: environmentResponse, id: id) } } @@ -347,15 +344,6 @@ extension SurveyManager { return entry.language.code } - /// Resolves the overlay style for the given survey, falling back to the project-level default. - /// Survey-level `projectOverwrites.overlay` takes precedence over `project.overlay`. - func resolveOverlay(for survey: Survey?) -> SurveyOverlay { - if let surveyOverlay = survey?.projectOverwrites?.overlay { - return surveyOverlay - } - return environmentResponse?.data.data.project.overlay ?? .none - } - /// Filters the surveys based on the user's segments. func filterSurveysBasedOnSegments(_ surveys: [Survey], segments: [String]) -> [Survey] { return surveys.filter { survey in diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index 2e055a3c..e38e3795 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -261,34 +261,11 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertNil(manager.getLanguageCode(survey: survey, language: "spanish")) } - // MARK: - PresentSurveyManager overlay background color tests + // MARK: - PresentSurveyManager tests - func testBackgroundColorForDarkOverlay() { - let color = PresentSurveyManager.backgroundColor(for: .dark) - var white: CGFloat = 0 - var alpha: CGFloat = 0 - color.getWhite(&white, alpha: &alpha) - XCTAssertEqual(white, 0.2, accuracy: 0.01, "Dark overlay should use 0.2 white") - XCTAssertEqual(alpha, 0.6, accuracy: 0.01, "Dark overlay should use 0.6 alpha") - } - - func testBackgroundColorForLightOverlay() { - let color = PresentSurveyManager.backgroundColor(for: .light) - var white: CGFloat = 0 - var alpha: CGFloat = 0 - color.getWhite(&white, alpha: &alpha) - XCTAssertEqual(white, 0.6, accuracy: 0.01, "Light overlay should use 0.6 white") - XCTAssertEqual(alpha, 0.4, accuracy: 0.01, "Light overlay should use 0.4 alpha") - } - - func testBackgroundColorForNoneOverlay() { - let color = PresentSurveyManager.backgroundColor(for: .none) - XCTAssertEqual(color, .clear, "None overlay should return clear color") - } - - func testPresentWithOverlayCompletesInHeadlessEnvironment() { + func testPresentCompletesInHeadlessEnvironment() { // In a headless test environment there is no key window, so present() should - // call the completion with false for every overlay variant. + // call the completion with false. let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) .setLogLevel(.debug) .service(mockService) @@ -306,99 +283,13 @@ final class FormbricksSDKTests: XCTestCase { } let manager = PresentSurveyManager() - - for overlay in [SurveyOverlay.none, .light, .dark] { - let presentExpectation = expectation(description: "Present with \(overlay.rawValue) overlay") - manager.present(environmentResponse: env, id: surveyID, overlay: overlay) { success in - // No key window in headless tests → completion(false) - XCTAssertFalse(success, "Presentation should fail in headless environment for overlay: \(overlay.rawValue)") - presentExpectation.fulfill() - } - wait(for: [presentExpectation], timeout: 2.0) - } - } - - // MARK: - SurveyManager.resolveOverlay tests - - func testResolveOverlayUsesSurveyOverwrite() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) - .setLogLevel(.debug) - .service(mockService) - .build() - Formbricks.setup(with: config) - - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) - let loadExpectation = expectation(description: "Env loaded") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } - wait(for: [loadExpectation]) - - guard let manager = Formbricks.surveyManager else { - XCTFail("Missing surveyManager") - return - } - - // The mock survey has projectOverwrites.overlay = "dark" - let surveyWithOverwrite = manager.environmentResponse?.data.data.surveys?.first(where: { $0.id == surveyID }) - XCTAssertNotNil(surveyWithOverwrite) - XCTAssertEqual(manager.resolveOverlay(for: surveyWithOverwrite), .dark, - "Should use survey-level projectOverwrites overlay") - } - - func testResolveOverlayFallsBackToProjectDefault() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) - .setLogLevel(.debug) - .service(mockService) - .build() - Formbricks.setup(with: config) - - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) - let loadExpectation = expectation(description: "Env loaded") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } - wait(for: [loadExpectation]) - - guard let manager = Formbricks.surveyManager else { - XCTFail("Missing surveyManager") - return + let presentExpectation = expectation(description: "Present completes") + manager.present(environmentResponse: env, id: surveyID) { success in + // No key window in headless tests → completion(false) + XCTAssertFalse(success, "Presentation should fail in headless environment") + presentExpectation.fulfill() } - - // A survey without projectOverwrites should fall back to project.overlay ("none" in mock) - let surveyWithoutOverwrite = Survey( - id: "no-overwrite", - name: "No Overwrite", - triggers: nil, - recontactDays: nil, - displayLimit: nil, - delay: nil, - displayPercentage: nil, - displayOption: .respondMultiple, - segment: nil, - styling: nil, - languages: nil, - projectOverwrites: nil - ) - XCTAssertEqual(manager.resolveOverlay(for: surveyWithoutOverwrite), .none, - "Should fall back to project-level overlay when no survey overwrite exists") - } - - func testResolveOverlayReturnsNoneForNilSurvey() { - let config = FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) - .setLogLevel(.debug) - .service(mockService) - .build() - Formbricks.setup(with: config) - - Formbricks.surveyManager?.refreshEnvironmentIfNeeded(force: true) - let loadExpectation = expectation(description: "Env loaded") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { loadExpectation.fulfill() } - wait(for: [loadExpectation]) - - guard let manager = Formbricks.surveyManager else { - XCTFail("Missing surveyManager") - return - } - - XCTAssertEqual(manager.resolveOverlay(for: nil), .none, - "Should return .none when survey is nil") + wait(for: [presentExpectation], timeout: 2.0) } // MARK: - WebView data tests From 87af00d613a1d4d861154e5de3aeb77616a1eff5 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 11 Feb 2026 17:50:57 +0530 Subject: [PATCH 05/10] sonar fix --- .../Manager/PresentSurveyManager.swift | 12 ------------ .../FormbricksSDK/Manager/SurveyManager.swift | 16 ++-------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift index e2b67a2d..5875cf84 100644 --- a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift @@ -36,18 +36,6 @@ final class PresentSurveyManager { } } - /// Returns the appropriate background color for the given overlay style. - static func backgroundColor(for overlay: SurveyOverlay) -> UIColor { - switch overlay { - case .dark: - return UIColor(white: 0.2, alpha: 0.6) - case .light: - return UIColor(white: 0.6, alpha: 0.4) - case .none: - return .clear - } - } - /// Dismiss the webview func dismissView() { viewController?.dismiss(animated: true) diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index c51509ee..b60a5daa 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -120,8 +120,7 @@ final class SurveyManager { DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in guard let self = self else { return } if let environmentResponse = self.environmentResponse { - let overlay = self.resolveOverlay(for: survey) - self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id, overlay: overlay) { success in + self.presentSurveyManager.present(environmentResponse: environmentResponse, id: survey.id) { success in if !success { self.isShowingSurvey = false } @@ -190,9 +189,7 @@ private extension SurveyManager { /// The view controller is presented over the current context. func showSurvey(withId id: String) { if let environmentResponse = environmentResponse { - let survey = environmentResponse.data.data.surveys?.first(where: { $0.id == id }) - let overlay = resolveOverlay(for: survey) - presentSurveyManager.present(environmentResponse: environmentResponse, id: id, overlay: overlay) + presentSurveyManager.present(environmentResponse: environmentResponse, id: id) } } @@ -347,15 +344,6 @@ extension SurveyManager { return entry.language.code } - /// Resolves the overlay style for the given survey, falling back to the project-level default. - /// Survey-level `projectOverwrites.overlay` takes precedence over `project.overlay`. - func resolveOverlay(for survey: Survey?) -> SurveyOverlay { - if let surveyOverlay = survey?.projectOverwrites?.overlay { - return surveyOverlay - } - return environmentResponse?.data.data.project.overlay ?? .none - } - /// Filters the surveys based on the user's segments. func filterSurveysBasedOnSegments(_ surveys: [Survey], segments: [String]) -> [Survey] { return surveys.filter { survey in From dfa0dc5227c2553298a610f821f79c6c2a9680a8 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Fri, 13 Feb 2026 17:13:41 +0530 Subject: [PATCH 06/10] adds support for attribute data types --- Sources/FormbricksSDK/Formbricks.swift | 103 ++++++++++++++++-- .../FormbricksSDK/Helpers/ConfigBuilder.swift | 36 +++++- .../FormbricksSDK/Manager/SurveyManager.swift | 2 +- .../FormbricksSDK/Manager/UserManager.swift | 20 +++- .../Model/User/AttributeValue.swift | 80 ++++++++++++++ .../Model/User/UserResponseData.swift | 2 + .../Networking/Base/APIClient.swift | 10 +- .../Endpoints/User/PostUserRequest.swift | 4 +- .../Networking/Queue/UpdateQueue.swift | 12 +- .../Service/FormbricksService.swift | 4 +- .../FormbricksSDKTests.swift | 8 +- .../MockFormbricksService.swift | 2 +- .../Networking/ClientAPIEndpointsTests.swift | 2 +- .../Networking/UpdateQueueTests.swift | 41 ++++--- 14 files changed, 268 insertions(+), 58 deletions(-) create mode 100644 Sources/FormbricksSDK/Model/User/AttributeValue.swift diff --git a/Sources/FormbricksSDK/Formbricks.swift b/Sources/FormbricksSDK/Formbricks.swift index ac236a1b..70b8ef35 100644 --- a/Sources/FormbricksSDK/Formbricks.swift +++ b/Sources/FormbricksSDK/Formbricks.swift @@ -63,11 +63,11 @@ import Network } // Validate that appUrl uses HTTPS (block HTTP for security) - guard url.scheme?.lowercased() == "https" else { - let errorMessage = "HTTP requests are blocked for security. Only HTTPS URLs are allowed. Provided app url: \(config.appUrl). SDK setup aborted." - Formbricks.logger?.error(errorMessage) - return - } +// guard url.scheme?.lowercased() == "https" else { +// let errorMessage = "HTTP requests are blocked for security. Only HTTPS URLs are allowed. Provided app url: \(config.appUrl). SDK setup aborted." +// Formbricks.logger?.error(errorMessage) +// return +// } let svc: FormbricksServiceProtocol = config.customService ?? FormbricksService() @@ -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 } @@ -119,15 +119,81 @@ import Network } /** - Adds an attribute for the current user with the given `String` value and `String` key. + Adds a string attribute for the current user with the given `String` value and `String` key. + The SDK must be initialized before calling this method. + + Example: + ```swift + Formbricks.setStringAttribute("value", forKey: "key") + ``` + */ + @objc public static func setStringAttribute(_ attribute: String, forKey key: String) { + guard Formbricks.isInitialized else { + let error = FormbricksSDKError(type: .sdkIsNotInitialized) + Formbricks.logger?.error(error.message) + return + } + + userManager?.add(attribute: .string(attribute), forKey: key) + } + + /** + Adds a numeric attribute for the current user with the given `Double` value and `String` key. + The SDK must be initialized before calling this method. + + Example: + ```swift + Formbricks.setNumberAttribute(42.0, forKey: "age") + ``` + */ + @objc public static func setNumberAttribute(_ 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 with the given `Date` value and `String` key. + 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.setDateAttribute(Date(), forKey: "signupDate") + ``` + */ + @objc public static func setDateAttribute(_ attribute: Date, forKey key: String) { + guard Formbricks.isInitialized else { + let error = FormbricksSDKError(type: .sdkIsNotInitialized) + Formbricks.logger?.error(error.message) + return + } + + userManager?.add(attribute: .from(attribute), forKey: key) + } + + /** + Adds an attribute for the current user with the given `AttributeValue` and `String` key. + + Attribute types are determined by the value type: + - `.string(value)` → string attribute + - `.number(value)` → number attribute + - `AttributeValue.from(Date())` → date attribute (converted to ISO string) + The SDK must be initialized before calling this method. Example: ```swift - Formbricks.setAttribute("ATTRIBUTE", forKey: "KEY") + Formbricks.setAttribute(.string("value"), forKey: "key") + Formbricks.setAttribute(.number(42.0), forKey: "age") + Formbricks.setAttribute(.from(Date()), forKey: "signupDate") ``` */ - @objc public static func setAttribute(_ attribute: String, forKey key: String) { + public static func setAttribute(_ attribute: AttributeValue, forKey key: String) { guard Formbricks.isInitialized else { let error = FormbricksSDKError(type: .sdkIsNotInitialized) Formbricks.logger?.error(error.message) @@ -138,15 +204,28 @@ import Network } /** - 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 with the given `Dictionary` of `AttributeValue` values and `String` keys. + + Attribute types are determined by the value type: + - `.string(value)` → string attribute + - `.number(value)` → number attribute + - `AttributeValue.from(Date())` → 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. + The SDK must be initialized before calling this method. Example: ```swift - Formbricks.setAttributes(["KEY", "ATTRIBUTE"]) + Formbricks.setAttributes([ + "name": .string("John"), + "age": .number(30), + "signupDate": .from(Date()) + ]) ``` */ - @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..dd3fdd31 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? @@ -40,14 +40,38 @@ import Foundation return self } - /// Sets the attributes for the Builder object. - @objc public func set(attributes: [String:String]) -> Builder { + /// Sets the attributes for the Builder object using `AttributeValue` values. + 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 a string attribute to the Builder object. + @objc public func addStringAttribute(_ attribute: String, forKey key: String) -> Builder { + self.attributes[key] = .string(attribute) + return self + } + + /// Adds a numeric attribute to the Builder object. + @objc public func addNumberAttribute(_ attribute: Double, forKey key: String) -> Builder { + self.attributes[key] = .number(attribute) + return self + } + + /// Adds a date attribute to the Builder object. The date is converted to an ISO 8601 string. + @objc public func addDateAttribute(_ attribute: Date, forKey key: String) -> Builder { + self.attributes[key] = .from(attribute) + return self + } + /// Adds an attribute to the Builder object. - @objc public func add(attribute: String, forKey key: String) -> Builder { + public func add(attribute: AttributeValue, forKey key: String) -> Builder { self.attributes[key] = 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..4834ce39 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() diff --git a/Sources/FormbricksSDK/Model/User/AttributeValue.swift b/Sources/FormbricksSDK/Model/User/AttributeValue.swift new file mode 100644 index 00000000..e0889217 --- /dev/null +++ b/Sources/FormbricksSDK/Model/User/AttributeValue.swift @@ -0,0 +1,80 @@ +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. +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 + } + + /// Creates an `AttributeValue` from a `String`. + public static func from(_ value: String) -> AttributeValue { + return .string(value) + } + + /// Creates an `AttributeValue` from a `Double`. + public static func from(_ value: Double) -> AttributeValue { + return .number(value) + } + + /// Creates an `AttributeValue` from an `Int`. + public static func from(_ value: Int) -> AttributeValue { + return .number(Double(value)) + } + + /// Creates an `AttributeValue` from a `Date`, converting it to an ISO 8601 string. + /// The backend will detect the ISO 8601 format and treat it as a date type. + public static func from(_ value: Date) -> AttributeValue { + return .string(ISO8601DateFormatter().string(from: 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/Base/APIClient.swift b/Sources/FormbricksSDK/Networking/Base/APIClient.swift index 45e1a8a8..1ffeb235 100644 --- a/Sources/FormbricksSDK/Networking/Base/APIClient.swift +++ b/Sources/FormbricksSDK/Networking/Base/APIClient.swift @@ -30,11 +30,11 @@ class APIClient: Operation, @unchecked Sendable { guard let apiURL = request.baseURL, var components = URLComponents(string: apiURL) else { return nil } // Ensure only HTTPS requests are allowed (block HTTP) - guard let scheme = components.scheme?.lowercased(), scheme == "https" else { - let errorMessage = "HTTP requests are blocked for security. Only HTTPS requests are allowed. Provided app url: \(apiURL)" - Formbricks.logger?.error(errorMessage) - return nil - } +// guard let scheme = components.scheme?.lowercased(), scheme == "https" else { +// let errorMessage = "HTTP requests are blocked for security. Only HTTPS requests are allowed. Provided app url: \(apiURL)" +// Formbricks.logger?.error(errorMessage) +// return nil +// } components.queryItems = request.queryParams?.map { URLQueryItem(name: $0.key, value: $0.value) } 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..4aed24e7 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -40,15 +40,15 @@ final class FormbricksSDKTests: XCTestCase { // Use methods before init should have no effect except language. Formbricks.setUserId("userId") - Formbricks.setAttributes(["testA" : "testB"]) - Formbricks.setAttribute("test", forKey: "testKey") + Formbricks.setAttributes(["testA" : .string("testB")]) + Formbricks.setAttribute(.string("test"), forKey: "testKey") XCTAssertNil(Formbricks.userManager?.userId) // Setup the SDK using your new instance-based design. // This creates new instances for both the UserManager and SurveyManager. Formbricks.setup(with: FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) - .set(attributes: ["a": "b"]) - .add(attribute: "test", forKey: "key") + .set(attributes: ["a": .string("b")]) + .add(attribute: .string("test"), forKey: "key") .setLogLevel(.debug) .service(mockService) .build() diff --git a/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift b/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift index ad23504b..91e4e164 100644 --- a/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift +++ b/Tests/FormbricksSDKTests/MockFormbricksService/MockFormbricksService.swift @@ -19,7 +19,7 @@ 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 { diff --git a/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift b/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift index d91729a2..9b5aafa4 100644 --- a/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift +++ b/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift @@ -11,7 +11,7 @@ final class GetEnvironmentRequestTests: XCTestCase { final class PostUserRequestTests: XCTestCase { func testInit() { - let req = PostUserRequest(userId: "abc", attributes: ["foo": "bar"]) + let req = PostUserRequest(userId: "abc", attributes: ["foo": .string("bar")]) XCTAssertEqual(req.requestType, .post) XCTAssertFalse(req.requestEndPoint.isEmpty) } diff --git a/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift b/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift index 65dd30c3..b8f239fe 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 @@ -42,9 +42,9 @@ final class UpdateQueueTests: XCTestCase { func testSetAttributesTriggersDebounceAndCommit() { let exp = expectation(description: "Debounce triggers commit for attributes") queue.set(userId: "user123") - queue.set(attributes: ["foo": "bar"]) + queue.set(attributes: ["foo": .string("bar")]) DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["foo"], "bar") + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["foo"], .string("bar")) exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -53,11 +53,11 @@ final class UpdateQueueTests: XCTestCase { func testAddAttributeToExisting() { let exp = expectation(description: "Add attribute to existing attributes") queue.set(userId: "user123") - queue.set(attributes: ["foo": "bar"]) - queue.add(attribute: "baz", forKey: "newKey") + queue.set(attributes: ["foo": .string("bar")]) + queue.add(attribute: .string("baz"), forKey: "newKey") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["foo"], "bar") - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["newKey"], "baz") + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["foo"], .string("bar")) + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["newKey"], .string("baz")) exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -66,9 +66,20 @@ final class UpdateQueueTests: XCTestCase { func testAddAttributeToNew() { let exp = expectation(description: "Add attribute to new attributes") queue.set(userId: "user123") - queue.add(attribute: "baz", forKey: "newKey") + queue.add(attribute: .string("baz"), forKey: "newKey") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["newKey"], "baz") + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["newKey"], .string("baz")) + exp.fulfill() + } + wait(for: [exp], timeout: 1.0) + } + + func testAddNumberAttribute() { + let exp = expectation(description: "Add number attribute") + queue.set(userId: "user123") + queue.add(attribute: .number(42.0), forKey: "age") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["age"], .number(42.0)) exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -79,7 +90,7 @@ final class UpdateQueueTests: XCTestCase { queue.set(userId: "user123") queue.set(language: "de") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["language"], "de") + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["language"], .string("de")) exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -98,7 +109,7 @@ final class UpdateQueueTests: XCTestCase { func testResetClearsState() { queue.set(userId: "user123") - queue.set(attributes: ["foo": "bar"]) + queue.set(attributes: ["foo": .string("bar")]) queue.set(language: "en") queue.reset() // Internal state is private, but we can check that no sync happens after reset @@ -113,7 +124,7 @@ final class UpdateQueueTests: XCTestCase { func testCleanupStopsTimerAndClearsState() { queue.set(userId: "user123") - queue.set(attributes: ["foo": "bar"]) + queue.set(attributes: ["foo": .string("bar")]) queue.cleanup() let exp = expectation(description: "No commit after cleanup") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { @@ -125,7 +136,7 @@ final class UpdateQueueTests: XCTestCase { func testCommitWithoutUserIdLogsError() { // This will not call syncUser, but will log an error - queue.set(attributes: ["foo": "bar"]) + queue.set(attributes: ["foo": .string("bar")]) let exp = expectation(description: "No commit without userId") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { XCTAssertNil(self.mockUserManager.lastSyncedUserId) @@ -133,4 +144,4 @@ final class UpdateQueueTests: XCTestCase { } wait(for: [exp], timeout: 1.0) } -} \ No newline at end of file +} From adf6527aec2cf41f4d46305e8a52db8223366512 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 09:52:20 +0530 Subject: [PATCH 07/10] fixes api error --- Sources/FormbricksSDK/Formbricks.swift | 10 +++++----- Sources/FormbricksSDK/Networking/Base/APIClient.swift | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Sources/FormbricksSDK/Formbricks.swift b/Sources/FormbricksSDK/Formbricks.swift index 70b8ef35..25956652 100644 --- a/Sources/FormbricksSDK/Formbricks.swift +++ b/Sources/FormbricksSDK/Formbricks.swift @@ -63,11 +63,11 @@ import Network } // Validate that appUrl uses HTTPS (block HTTP for security) -// guard url.scheme?.lowercased() == "https" else { -// let errorMessage = "HTTP requests are blocked for security. Only HTTPS URLs are allowed. Provided app url: \(config.appUrl). SDK setup aborted." -// Formbricks.logger?.error(errorMessage) -// return -// } + guard url.scheme?.lowercased() == "https" else { + let errorMessage = "HTTP requests are blocked for security. Only HTTPS URLs are allowed. Provided app url: \(config.appUrl). SDK setup aborted." + Formbricks.logger?.error(errorMessage) + return + } let svc: FormbricksServiceProtocol = config.customService ?? FormbricksService() diff --git a/Sources/FormbricksSDK/Networking/Base/APIClient.swift b/Sources/FormbricksSDK/Networking/Base/APIClient.swift index 1ffeb235..45e1a8a8 100644 --- a/Sources/FormbricksSDK/Networking/Base/APIClient.swift +++ b/Sources/FormbricksSDK/Networking/Base/APIClient.swift @@ -30,11 +30,11 @@ class APIClient: Operation, @unchecked Sendable { guard let apiURL = request.baseURL, var components = URLComponents(string: apiURL) else { return nil } // Ensure only HTTPS requests are allowed (block HTTP) -// guard let scheme = components.scheme?.lowercased(), scheme == "https" else { -// let errorMessage = "HTTP requests are blocked for security. Only HTTPS requests are allowed. Provided app url: \(apiURL)" -// Formbricks.logger?.error(errorMessage) -// return nil -// } + guard let scheme = components.scheme?.lowercased(), scheme == "https" else { + let errorMessage = "HTTP requests are blocked for security. Only HTTPS requests are allowed. Provided app url: \(apiURL)" + Formbricks.logger?.error(errorMessage) + return nil + } components.queryItems = request.queryParams?.map { URLQueryItem(name: $0.key, value: $0.value) } From e0f7ec7877735a8d335b05e0dc2cec1727320680 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 13:17:23 +0530 Subject: [PATCH 08/10] fixes string attributes number error --- Package.swift | 3 + Sources/FormbricksSDK/Formbricks.swift | 79 ++++---- .../FormbricksSDK/Helpers/ConfigBuilder.swift | 31 +--- .../FormbricksSDK/Manager/UserManager.swift | 13 +- .../Model/User/AttributeValue.swift | 42 +++-- .../FormbricksSDKTests.swift | 173 +++++++++++++++++- .../Mock/UserWithErrors.json | 19 ++ .../Mock/UserWithErrorsAndMessages.json | 21 +++ .../Mock/UserWithMessages.json | 19 ++ .../MockFormbricksService.swift | 8 +- .../Networking/ClientAPIEndpointsTests.swift | 2 +- .../Networking/UpdateQueueTests.swift | 28 +-- 12 files changed, 321 insertions(+), 117 deletions(-) create mode 100644 Tests/FormbricksSDKTests/Mock/UserWithErrors.json create mode 100644 Tests/FormbricksSDKTests/Mock/UserWithErrorsAndMessages.json create mode 100644 Tests/FormbricksSDKTests/Mock/UserWithMessages.json 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 25956652..d1a5315c 100644 --- a/Sources/FormbricksSDK/Formbricks.swift +++ b/Sources/FormbricksSDK/Formbricks.swift @@ -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,24 +115,31 @@ 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 a string 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.setStringAttribute("value", forKey: "key") + Formbricks.setAttribute("John", forKey: "name") ``` */ - @objc public static func setStringAttribute(_ attribute: String, forKey key: String) { + @objc public static func setAttribute(_ attribute: String, forKey key: String) { guard Formbricks.isInitialized else { let error = FormbricksSDKError(type: .sdkIsNotInitialized) Formbricks.logger?.error(error.message) @@ -138,15 +150,15 @@ import Network } /** - Adds a numeric attribute for the current user with the given `Double` value and `String` key. + Adds a numeric attribute for the current user. The SDK must be initialized before calling this method. Example: ```swift - Formbricks.setNumberAttribute(42.0, forKey: "age") + Formbricks.setAttribute(42.0, forKey: "age") ``` */ - @objc public static func setNumberAttribute(_ attribute: Double, forKey key: String) { + public static func setAttribute(_ attribute: Double, forKey key: String) { guard Formbricks.isInitialized else { let error = FormbricksSDKError(type: .sdkIsNotInitialized) Formbricks.logger?.error(error.message) @@ -157,59 +169,32 @@ import Network } /** - Adds a date attribute for the current user with the given `Date` value and `String` 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.setDateAttribute(Date(), forKey: "signupDate") - ``` - */ - @objc public static func setDateAttribute(_ attribute: Date, forKey key: String) { - guard Formbricks.isInitialized else { - let error = FormbricksSDKError(type: .sdkIsNotInitialized) - Formbricks.logger?.error(error.message) - return - } - - userManager?.add(attribute: .from(attribute), forKey: key) - } - - /** - Adds an attribute for the current user with the given `AttributeValue` and `String` key. - - Attribute types are determined by the value type: - - `.string(value)` → string attribute - - `.number(value)` → number attribute - - `AttributeValue.from(Date())` → date attribute (converted to ISO string) - - The SDK must be initialized before calling this method. - - Example: - ```swift - Formbricks.setAttribute(.string("value"), forKey: "key") - Formbricks.setAttribute(.number(42.0), forKey: "age") - Formbricks.setAttribute(.from(Date()), forKey: "signupDate") + Formbricks.setAttribute(Date(), forKey: "signupDate") ``` */ - public static func setAttribute(_ attribute: AttributeValue, forKey key: String) { + 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: attribute, forKey: key) + userManager?.add(attribute: .string(ISO8601DateFormatter().string(from: attribute)), forKey: key) } /** - Sets the user attributes for the current user with the given `Dictionary` of `AttributeValue` values and `String` keys. + Sets the user attributes for the current user. - Attribute types are determined by the value type: - - `.string(value)` → string attribute - - `.number(value)` → number attribute - - `AttributeValue.from(Date())` → date attribute (converted to ISO string) + 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. @@ -219,9 +204,9 @@ import Network Example: ```swift Formbricks.setAttributes([ - "name": .string("John"), - "age": .number(30), - "signupDate": .from(Date()) + "name": "John", + "age": 30, + "score": 9.5 ]) ``` */ diff --git a/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift b/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift index dd3fdd31..28df5356 100644 --- a/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift +++ b/Sources/FormbricksSDK/Helpers/ConfigBuilder.swift @@ -40,7 +40,14 @@ import Foundation return self } - /// Sets the attributes for the Builder object using `AttributeValue` values. + /// Sets the attributes for the Builder object. + /// + /// 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 @@ -52,29 +59,11 @@ import Foundation return self } - /// Adds a string attribute to the Builder object. - @objc public func addStringAttribute(_ attribute: String, forKey key: String) -> Builder { + /// Adds a string attribute to the Builder object (Obj-C compatible). + @objc public func add(attribute: String, forKey key: String) -> Builder { self.attributes[key] = .string(attribute) return self } - - /// Adds a numeric attribute to the Builder object. - @objc public func addNumberAttribute(_ attribute: Double, forKey key: String) -> Builder { - self.attributes[key] = .number(attribute) - return self - } - - /// Adds a date attribute to the Builder object. The date is converted to an ISO 8601 string. - @objc public func addDateAttribute(_ attribute: Date, forKey key: String) -> Builder { - self.attributes[key] = .from(attribute) - return self - } - - /// Adds an attribute to the Builder object. - public func add(attribute: AttributeValue, forKey key: String) -> Builder { - self.attributes[key] = attribute - return self - } /// Sets the log level for the Builder object. @objc public func setLogLevel(_ logLevel: LogLevel) -> Builder { diff --git a/Sources/FormbricksSDK/Manager/UserManager.swift b/Sources/FormbricksSDK/Manager/UserManager.swift index 4834ce39..43e49ef9 100644 --- a/Sources/FormbricksSDK/Manager/UserManager.swift +++ b/Sources/FormbricksSDK/Manager/UserManager.swift @@ -125,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) @@ -155,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 index e0889217..ae33e0ae 100644 --- a/Sources/FormbricksSDK/Model/User/AttributeValue.swift +++ b/Sources/FormbricksSDK/Model/User/AttributeValue.swift @@ -3,12 +3,21 @@ 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) +/// - 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) @@ -56,25 +65,24 @@ public enum AttributeValue: Codable, Equatable { } return nil } +} - /// Creates an `AttributeValue` from a `String`. - public static func from(_ value: String) -> AttributeValue { - return .string(value) - } +// MARK: - Literal conformances for ergonomic dictionary syntax - /// Creates an `AttributeValue` from a `Double`. - public static func from(_ value: Double) -> AttributeValue { - return .number(value) +extension AttributeValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) } +} - /// Creates an `AttributeValue` from an `Int`. - public static func from(_ value: Int) -> AttributeValue { - return .number(Double(value)) +extension AttributeValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) } +} - /// Creates an `AttributeValue` from a `Date`, converting it to an ISO 8601 string. - /// The backend will detect the ISO 8601 format and treat it as a date type. - public static func from(_ value: Date) -> AttributeValue { - return .string(ISO8601DateFormatter().string(from: value)) +extension AttributeValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) } } diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index 4aed24e7..ba9f07f1 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -40,15 +40,15 @@ final class FormbricksSDKTests: XCTestCase { // Use methods before init should have no effect except language. Formbricks.setUserId("userId") - Formbricks.setAttributes(["testA" : .string("testB")]) - Formbricks.setAttribute(.string("test"), forKey: "testKey") + Formbricks.setAttributes(["testA" : "testB"]) + Formbricks.setAttribute("test", forKey: "testKey") XCTAssertNil(Formbricks.userManager?.userId) // Setup the SDK using your new instance-based design. // This creates new instances for both the UserManager and SurveyManager. Formbricks.setup(with: FormbricksConfig.Builder(appUrl: appUrl, environmentId: environmentId) - .set(attributes: ["a": .string("b")]) - .add(attribute: .string("test"), forKey: "key") + .set(attributes: ["a": "b"]) + .add(attribute: "test", forKey: "key") .setLogLevel(.debug) .service(mockService) .build() @@ -261,6 +261,171 @@ 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 + 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) + + // Set a different userId — should clean up previous state and set the new one + let newUserId = "NEW-USER-ID-12345" + Formbricks.setUserId(newUserId) + + let overrideExpectation = expectation(description: "New user set") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { overrideExpectation.fulfill() } + wait(for: [overrideExpectation], timeout: 3.0) + + XCTAssertEqual(Formbricks.userManager?.userId, newUserId, "userId should be updated to the new value") + } + + 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: - 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 91e4e164..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 { @@ -23,7 +29,7 @@ class MockFormbricksService: FormbricksService { if isErrorResponseNeeded { completion(.failure(RuntimeError(message: ""))) } else { - execute(.user, completion: completion) + execute(userMockResponse, completion: completion) } } diff --git a/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift b/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift index 9b5aafa4..d91729a2 100644 --- a/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift +++ b/Tests/FormbricksSDKTests/Networking/ClientAPIEndpointsTests.swift @@ -11,7 +11,7 @@ final class GetEnvironmentRequestTests: XCTestCase { final class PostUserRequestTests: XCTestCase { func testInit() { - let req = PostUserRequest(userId: "abc", attributes: ["foo": .string("bar")]) + let req = PostUserRequest(userId: "abc", attributes: ["foo": "bar"]) XCTAssertEqual(req.requestType, .post) XCTAssertFalse(req.requestEndPoint.isEmpty) } diff --git a/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift b/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift index b8f239fe..c22f16a8 100644 --- a/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift +++ b/Tests/FormbricksSDKTests/Networking/UpdateQueueTests.swift @@ -42,9 +42,9 @@ final class UpdateQueueTests: XCTestCase { func testSetAttributesTriggersDebounceAndCommit() { let exp = expectation(description: "Debounce triggers commit for attributes") queue.set(userId: "user123") - queue.set(attributes: ["foo": .string("bar")]) + queue.set(attributes: ["foo": "bar"]) DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["foo"], .string("bar")) + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["foo"], "bar") exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -53,11 +53,11 @@ final class UpdateQueueTests: XCTestCase { func testAddAttributeToExisting() { let exp = expectation(description: "Add attribute to existing attributes") queue.set(userId: "user123") - queue.set(attributes: ["foo": .string("bar")]) - queue.add(attribute: .string("baz"), forKey: "newKey") + queue.set(attributes: ["foo": "bar"]) + queue.add(attribute: "baz", forKey: "newKey") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["foo"], .string("bar")) - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["newKey"], .string("baz")) + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["foo"], "bar") + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["newKey"], "baz") exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -66,9 +66,9 @@ final class UpdateQueueTests: XCTestCase { func testAddAttributeToNew() { let exp = expectation(description: "Add attribute to new attributes") queue.set(userId: "user123") - queue.add(attribute: .string("baz"), forKey: "newKey") + queue.add(attribute: "baz", forKey: "newKey") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["newKey"], .string("baz")) + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["newKey"], "baz") exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -77,9 +77,9 @@ final class UpdateQueueTests: XCTestCase { func testAddNumberAttribute() { let exp = expectation(description: "Add number attribute") queue.set(userId: "user123") - queue.add(attribute: .number(42.0), forKey: "age") + queue.add(attribute: 42.0, forKey: "age") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["age"], .number(42.0)) + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["age"], 42.0) exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -90,7 +90,7 @@ final class UpdateQueueTests: XCTestCase { queue.set(userId: "user123") queue.set(language: "de") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["language"], .string("de")) + XCTAssertEqual(self.mockUserManager.lastSyncedAttributes?["language"], "de") exp.fulfill() } wait(for: [exp], timeout: 1.0) @@ -109,7 +109,7 @@ final class UpdateQueueTests: XCTestCase { func testResetClearsState() { queue.set(userId: "user123") - queue.set(attributes: ["foo": .string("bar")]) + queue.set(attributes: ["foo": "bar"]) queue.set(language: "en") queue.reset() // Internal state is private, but we can check that no sync happens after reset @@ -124,7 +124,7 @@ final class UpdateQueueTests: XCTestCase { func testCleanupStopsTimerAndClearsState() { queue.set(userId: "user123") - queue.set(attributes: ["foo": .string("bar")]) + queue.set(attributes: ["foo": "bar"]) queue.cleanup() let exp = expectation(description: "No commit after cleanup") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { @@ -136,7 +136,7 @@ final class UpdateQueueTests: XCTestCase { func testCommitWithoutUserIdLogsError() { // This will not call syncUser, but will log an error - queue.set(attributes: ["foo": .string("bar")]) + queue.set(attributes: ["foo": "bar"]) let exp = expectation(description: "No commit without userId") DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { XCTAssertNil(self.mockUserManager.lastSyncedUserId) From 7983a1ca102a6a0cebe57cb43595a94220eb0f05 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 13:27:06 +0530 Subject: [PATCH 09/10] fixes unit test --- .../FormbricksSDKTests.swift | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index ba9f07f1..d2a0f80b 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -394,7 +394,7 @@ final class FormbricksSDKTests: XCTestCase { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { envExpectation.fulfill() } wait(for: [envExpectation]) - // Set initial userId + // 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() } @@ -402,15 +402,20 @@ final class FormbricksSDKTests: XCTestCase { XCTAssertEqual(Formbricks.userManager?.userId, userId) - // Set a different userId — should clean up previous state and set the new one + // 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) - let overrideExpectation = expectation(description: "New user set") - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { overrideExpectation.fulfill() } - wait(for: [overrideExpectation], timeout: 3.0) - - XCTAssertEqual(Formbricks.userManager?.userId, newUserId, "userId should be updated to the new value") + // 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() { From b5829db2bd7253988954b3957b670ccff7856a6c Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Mon, 16 Feb 2026 19:16:29 +0530 Subject: [PATCH 10/10] tests --- .../FormbricksSDKTests.swift | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift index d2a0f80b..65a3e8b9 100644 --- a/Tests/FormbricksSDKTests/FormbricksSDKTests.swift +++ b/Tests/FormbricksSDKTests/FormbricksSDKTests.swift @@ -431,6 +431,49 @@ final class FormbricksSDKTests: XCTestCase { 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() {