diff --git a/Sources/FormbricksSDK/Formbricks.swift b/Sources/FormbricksSDK/Formbricks.swift index 0a30b16..8212bf2 100644 --- a/Sources/FormbricksSDK/Formbricks.swift +++ b/Sources/FormbricksSDK/Formbricks.swift @@ -135,6 +135,11 @@ import Network if let existing = userManager?.userId, !existing.isEmpty { logger?.debug("Different userId is being set, cleaning up previous user state") userManager?.logout() + // An identity switch: the ambient Embedded Data bag may carry the previous user's + // context, which must not ride onto the next user's responses on a shared device. + // First-time identification keeps the bag — a host legitimately pushes context before + // it knows who the user is. + EmbeddedDataManager.shared.removeAll() } userManager?.set(userId: userId) @@ -279,6 +284,71 @@ import Network } + /** + Attaches Embedded Data to future responses without tying it to a trigger. + + Merges into an in-memory bag — last write wins per key, and an explicit `nil` removes a key. + Values land only on the survey's declared *ingested* fields; anything else is dropped and + logged by the survey renderer, never fatal. + + **`nil` removes; there is no "leave this alone" value.** Swift has no `undefined`, so this SDK + maps `nil` onto the JS SDK's `{ key: null }` (remove) and has nothing that spells its + `{ key: undefined }` (no-op). A host porting the cross-platform idiom of passing every field + unconditionally — `["plan": user.plan.map(EmbeddedDataValue.string)]` — therefore *clears* + `plan` here whenever the optional is empty, where the same code on web would leave the previous + value standing. Build the dictionary from the keys you actually have, or use + `clearEmbeddedData(_:)` when you mean to remove one. + + Deliberately callable **before** `setup(with:)`, unlike the methods above: a host that pushes + context at launch must not have that value silently dropped because initialization had not + finished. The bag is pure memory — nothing here needs the SDK to be running. + + The bag is snapshotted when a survey is displayed and frozen for its lifetime, so a value set + while a survey is on screen reaches the *next* response, not that one. It is never persisted: + a cold app start begins empty and the host re-pushes. + + Example: + ```swift + Formbricks.setEmbeddedData([ + "plan": "pro", + "seats": 25, + "isTrial": false, + "screen": nil, // removes the key + ]) + ``` + */ + public static func setEmbeddedData(_ data: [String: EmbeddedDataValue?]) { + EmbeddedDataManager.shared.set(data) + } + + /** + Removes one Embedded Data key. A key that was never set is a no-op. + + The single-key and clear-everything forms are separate overloads on purpose: a `String` that + cannot be `nil` means a host reading the key from its own state cannot accidentally wipe the + whole bag. + + Example: + ```swift + Formbricks.clearEmbeddedData("plan") + ``` + */ + public static func clearEmbeddedData(_ key: String) { + EmbeddedDataManager.shared.remove(key: key) + } + + /** + Clears the whole Embedded Data bag — logout, or a hard context switch. + + Example: + ```swift + Formbricks.clearEmbeddedData() + ``` + */ + public static func clearEmbeddedData() { + EmbeddedDataManager.shared.removeAll() + } + /** Logs out the current user. This will clear the user attributes and the user id. The SDK must be initialized before calling this method. @@ -296,6 +366,9 @@ import Network } userManager?.logout() + // Same identity-switch rule as setUserId: logout must not let the previous user's ambient + // context leak onto whoever uses the app next. + EmbeddedDataManager.shared.removeAll() } /** @@ -332,6 +405,10 @@ import Network } private static func performCleanup() { + // An explicit full reset of the SDK, so the host-supplied context goes too. This is a + // stronger teardown than the JS SDK's internal one, which deliberately preserves the bag + // across a setup retry — `cleanup()` is not a retry, it is the host saying "forget it all". + EmbeddedDataManager.shared.removeAll() userManager?.logout() userManager?.cleanupUpdateQueue() presentSurveyManager?.dismissView() diff --git a/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift b/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift new file mode 100644 index 0000000..1e69096 --- /dev/null +++ b/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift @@ -0,0 +1,130 @@ +import Foundation + +/// The in-memory Embedded Data bag: context a host app attaches to future responses without tying +/// it to a trigger — `Formbricks.setEmbeddedData(["screen": "checkout"])` once, instead of +/// repeating the same values on every possible `track(...)` call. +/// +/// Mirrors the JS SDK's store key for key, so web and mobile behave identically. +/// +/// Lifetime rules, all deliberate: +/// +/// - **In-memory, process scoped, never persisted.** Not `UserDefaults`: persisting this bag would +/// blur the Embedded Data ↔ contact-attribute boundary and create a stale-data / PII-at-rest +/// surface. A cold app start begins empty; the host re-pushes. +/// - **Snapshot at display, then frozen.** The WebView payload copies the bag when the survey is +/// shown, so a later `setEmbeddedData` affects the next response, never the one on screen. +/// - **No filtering here.** The SDK is a dumb pipe: the survey renderer applies the ingest contract +/// — allow-list, coercion, `locked`, size caps — and logs what it refuses, and the server re-runs +/// all of it on ingest. Filtering here would ship a second copy of those rules for the four mobile +/// SDKs to drift from. +/// - **Independent of `setup`.** A singleton rather than a manager hung off `Formbricks`, because a +/// host legitimately pushes context before the SDK finishes initializing, and silently dropping +/// that write is the failure this API exists to avoid. `Formbricks.cleanup()` clears it, since +/// that is an explicit full reset of the SDK. +/// - **No network.** Every method is a synchronous memory write, so calling it on every screen +/// change is free. Values ride the existing response payload. +final class EmbeddedDataManager { + static let shared = EmbeddedDataManager() + + /// Same idiom as `UpdateQueue`: the host may call from any thread, and the snapshot is read on + /// the main queue while a survey is being presented. + private let syncQueue = DispatchQueue(label: "com.formbricks.embeddedData") + private var data: [String: EmbeddedDataValue] = [:] + + private init() { + /* + Private so the bag cannot be instantiated a second time: a host holding its own copy would + write into a store the survey payload never reads. + */ + } + + /// Merge — never replace — so refreshing a volatile field (`screen`) cannot wipe the stable ones + /// (`plan`) set at launch. Per key: last write wins, and an explicit `nil` removes the key. + /// + /// A key the caller simply leaves out is untouched; that is how a host skips a field it has no + /// value for this screen. `nil` is the deliberate "remove this" spelling, matching the JS SDK's + /// `{ key: null }`. + func set(_ data: [String: EmbeddedDataValue?]) { + var setKeys: [String] = [] + var removedKeys: [String] = [] + var held: [String] = [] + syncQueue.sync { + for (key, value) in data { + guard let value = value else { + self.data.removeValue(forKey: key) + removedKeys.append(key) + continue + } + // Refused rather than stored: `JSONSerialization` throws on a non-finite Double, and + // the payload it would refuse is the whole survey's props blob — one bad value would + // cost the survey, not the field. Never fatal, always logged. + if case .number(let number) = value, !number.isFinite { + Formbricks.logger?.error( + "setEmbeddedData: \"\(key)\" is not a finite number — the key was skipped") + continue + } + self.data[key] = value + setKeys.append(key) + } + held = Array(self.data.keys) + } + // Built and logged outside the queue, so a log write never holds it. + Formbricks.logger?.debug( + EmbeddedDataManager.setTrace(set: setKeys, removed: removedKeys, held: held)) + } + + /// The success trace, because the bag is otherwise invisible: it lives in memory (nothing in + /// `UserDefaults` to inspect) and the API has no getter, so without this line a host wiring up + /// `setEmbeddedData` gets no confirmation until a survey happens to display. Logged at `.debug`, + /// so it is silent at the default log level. + /// + /// Keys only, never values: the documented use of this bag includes hashed identity fields. + /// Separated from the logging call so that property is directly assertable in a test. + /// + /// Every list is sorted, unlike the other three SDKs, which preserve insertion order: the bag + /// and the caller's argument are both Swift `Dictionary`s, whose iteration order is not + /// specified and varies per process. Sorting is the only way this line reads the same twice. + static func setTrace(set setKeys: [String], removed removedKeys: [String], held: [String]) + -> String + { + let removed = + removedKeys.isEmpty ? "" : ", removed [\(removedKeys.sorted().joined(separator: ", "))]" + return "setEmbeddedData: set [\(setKeys.sorted().joined(separator: ", "))]\(removed) — the " + + "bag now holds [\(held.sorted().joined(separator: ", "))]. Keys land on a response " + + "only if the survey declares them as ingested Embedded Data fields." + } + + /// Removes one key. A key that is not set is a no-op. + func remove(key: String) { + var held: [String] = [] + syncQueue.sync { + _ = data.removeValue(forKey: key) + held = Array(data.keys) + } + Formbricks.logger?.debug( + "clearEmbeddedData: removed \"\(key)\" — the bag now holds " + + "[\(held.sorted().joined(separator: ", "))]") + } + + /// Removes everything — logout, or a hard context switch. + func removeAll() { + var clearedCount = 0 + syncQueue.sync { + clearedCount = data.count + data.removeAll() + } + Formbricks.logger?.debug("clearEmbeddedData: cleared the whole bag (\(clearedCount) keys)") + } + + /// A detached, JSON-safe copy for the display-time snapshot: mutating the bag after a survey has + /// rendered must not reach that survey's response. + func snapshot() -> [String: Any] { + syncQueue.sync { + data.reduce(into: [String: Any]()) { result, entry in + if let value = entry.value.jsonValue { + result[entry.key] = value + } + } + } + } +} diff --git a/Sources/FormbricksSDK/Model/EmbeddedData/EmbeddedDataValue.swift b/Sources/FormbricksSDK/Model/EmbeddedData/EmbeddedDataValue.swift new file mode 100644 index 0000000..3394897 --- /dev/null +++ b/Sources/FormbricksSDK/Model/EmbeddedData/EmbeddedDataValue.swift @@ -0,0 +1,72 @@ +import Foundation + +/// A value a host app may attach to future responses with ``Formbricks/setEmbeddedData(_:)``. +/// +/// Confined to the four scalars the Embedded Data ingest contract can store. A closed enum rather +/// than `Any` on purpose: the bag is serialized into the survey WebView's payload, so an +/// unrepresentable value would not be a dropped field but a `JSONSerialization` failure that takes +/// the whole survey down with it. +/// +/// Supports literal syntax in dictionaries, so the common call reads as plain data: +/// +/// ```swift +/// Formbricks.setEmbeddedData([ +/// "plan": "pro", +/// "seats": 25, +/// "isTrial": false, +/// "screen": nil, // removes the key +/// ]) +/// ``` +/// +/// Dates serialize as ISO 8601, which is what the ingest contract accepts for a `date` field. +public enum EmbeddedDataValue: Equatable { + case string(String) + case number(Double) + case bool(Bool) + case date(Date) + + /// A `JSONSerialization`-safe representation, or `nil` for a value that cannot be serialized. + /// + /// The `nil` case is not defensive habit. `JSONSerialization` **throws** on a non-finite + /// `Double`, and the payload it would have refused is the whole survey's props blob — so a + /// single `.number(.nan)` would mean no survey rather than one missing field. The store rejects + /// those at the door and logs; this is the second line of the same rule. + var jsonValue: Any? { + switch self { + case .string(let value): + return value + case .number(let value): + return value.isFinite ? value : nil + case .bool(let value): + return value + case .date(let value): + return ISO8601DateFormatter().string(from: value) + } + } +} + +// MARK: - Literal conformances for ergonomic dictionary syntax + +extension EmbeddedDataValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension EmbeddedDataValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension EmbeddedDataValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension EmbeddedDataValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} diff --git a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift index 7c86728..d01e646 100644 --- a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift +++ b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift @@ -117,6 +117,12 @@ private class WebViewData { data["environmentId"] = Formbricks.workspaceId data["contactId"] = Formbricks.userManager?.contactId data["isWebEnvironment"] = false + // The Embedded Data bag, snapshotted here — this initializer runs when the survey is + // actually presented, after any configured delay — and frozen for the survey's life. Passed + // raw and unfiltered: the ingest contract (allow-list, coercion, `locked`, size caps) lives + // in the renderer, so all four mobile SDKs inherit the same rules without each shipping a + // copy, and the server re-runs all of it on ingest. + data["hiddenFieldsRecord"] = EmbeddedDataManager.shared.snapshot() data["isBrandingEnabled"] = settings.inAppSurveyBranding ?? true if let placementEnum = matchedSurvey?.projectOverwrites?.placement { diff --git a/Tests/FormbricksSDKTests/EmbeddedDataTests.swift b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift new file mode 100644 index 0000000..abe1aff --- /dev/null +++ b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift @@ -0,0 +1,325 @@ +import XCTest +@testable import FormbricksSDK + +/// The Embedded Data bag (ENG-1844 / ENG-2472): host-supplied context attached to future responses +/// without tying it to a trigger. These pin the contract the four SDKs share, so a divergence here +/// is a divergence from the JS SDK too. +final class EmbeddedDataTests: XCTestCase { + + override func setUp() { + super.setUp() + Formbricks.cleanup() + clearPersistedUserId() + EmbeddedDataManager.shared.removeAll() + } + + override func tearDown() { + clearPersistedUserId() + EmbeddedDataManager.shared.removeAll() + Formbricks.cleanup() + super.tearDown() + } + + /// Removes a seeded identity on both sides of every test. + /// + /// `Formbricks.cleanup()` clears it only when a `UserManager` exists to log out, which it does + /// not before the first `setup`. Without this, a seeded id would outlive this class — which + /// runs first alphabetically — and `FormbricksSDKTests` asserts `userManager?.userId` is nil + /// before setup. + private func clearPersistedUserId() { + UserDefaults.standard.removeObject(forKey: "userIdKey") + } + + /// Initializes the SDK against the mock service, so identity changes can be exercised without + /// the network. + private func setUpSdk() { + Formbricks.setup( + with: FormbricksConfig.Builder(appUrl: "https://app.formbricks.com", workspaceId: "ws-1") + .setLogLevel(.debug) + .service(MockFormbricksService()) + .build()) + } + + /// Seeds a persisted identity, the way a previous app session would have left one. + /// + /// Not `Formbricks.setUserId`: that only enqueues into the debounced `UpdateQueue`, so + /// `userManager?.userId` stays nil until a network sync lands, and a test driving it that way + /// would silently take the first-identification branch and pass for the wrong reason. Writing + /// the same `UserDefaults` key the getter falls back to is exact, needs no timer or request, and + /// models the honest scenario — the app relaunches already identified, then a different user + /// signs in. + /// + /// Must be called **before** `setUpSdk()`: `UserManager` caches `backingUserId` on first read, + /// so the value has to be in place when the fresh manager is created. The key is + /// `UserManager`'s own private constant, repeated here because that is the storage contract + /// this seeds. + private func seedPersistedUserId(_ userId: String) { + UserDefaults.standard.set(userId, forKey: "userIdKey") + } + + /// `snapshot()` returns `[String: Any]`, so comparisons go through `NSDictionary`, which + /// compares element-wise with the bridged equality each value type already has. + private func assertSnapshot(_ expected: [String: Any], file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual( + EmbeddedDataManager.shared.snapshot() as NSDictionary, + expected as NSDictionary, + file: file, + line: line + ) + } + + // MARK: - Merge semantics + + func testMergesInsteadOfReplacing() { + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product"]) + Formbricks.setEmbeddedData(["screen": "checkout"]) + + assertSnapshot(["plan": "pro", "screen": "checkout"]) + } + + func testNilRemovesTheKey() { + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product"]) + Formbricks.setEmbeddedData(["screen": nil]) + + assertSnapshot(["plan": "pro"]) + } + + func testLastWriteWinsPerKey() { + Formbricks.setEmbeddedData(["plan": "free"]) + Formbricks.setEmbeddedData(["plan": "pro"]) + + assertSnapshot(["plan": "pro"]) + } + + func testOmittedKeysAreUntouched() { + // Swift has no `undefined`, so "skip this field" is spelled by leaving the key out — and + // that must not disturb what an earlier call set. `nil` is the explicit "remove" spelling. + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product"]) + Formbricks.setEmbeddedData(["seats": 4]) + + assertSnapshot(["plan": "pro", "screen": "product", "seats": 4.0]) + } + + // MARK: - Clearing + + func testClearOneKeyLeavesTheRest() { + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product", "seats": 4]) + + Formbricks.clearEmbeddedData("screen") + + assertSnapshot(["plan": "pro", "seats": 4.0]) + } + + func testClearingAnUnsetKeyIsANoOp() { + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.clearEmbeddedData("neverSet") + + assertSnapshot(["plan": "pro"]) + } + + func testClearEverything() { + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product"]) + + Formbricks.clearEmbeddedData() + + assertSnapshot([:]) + } + + // MARK: - Value types + + func testEveryScalarSurvivesInItsJsonForm() { + let signedUpAt = Date(timeIntervalSince1970: 1_787_000_000) + + Formbricks.setEmbeddedData([ + "plan": "pro", + "seats": 25, + "score": 9.5, + "isTrial": false, + "signedUpAt": .date(signedUpAt), + ]) + + let snapshot = EmbeddedDataManager.shared.snapshot() + XCTAssertEqual(snapshot["plan"] as? String, "pro") + XCTAssertEqual(snapshot["seats"] as? Double, 25) + XCTAssertEqual(snapshot["score"] as? Double, 9.5) + XCTAssertEqual(snapshot["isTrial"] as? Bool, false) + // ISO 8601 is what the renderer's ingest contract accepts for a `date` field. + XCTAssertEqual(snapshot["signedUpAt"] as? String, ISO8601DateFormatter().string(from: signedUpAt)) + } + + func testASnapshotIsSerializableAsJson() { + // The snapshot is embedded in the survey WebView's props blob, which goes through + // JSONSerialization. If it ever threw, the failure would not be a missing field — it would + // be no survey at all. + Formbricks.setEmbeddedData([ + "plan": "pro", + "seats": 25, + "isTrial": true, + "signedUpAt": .date(Date()), + ]) + + XCTAssertTrue(JSONSerialization.isValidJSONObject(EmbeddedDataManager.shared.snapshot())) + XCTAssertNoThrow( + try JSONSerialization.data(withJSONObject: EmbeddedDataManager.shared.snapshot(), options: [])) + } + + func testANonFiniteNumberIsSkippedRatherThanCostingTheSurvey() { + // THE guard: JSONSerialization throws on a non-finite Double, and the payload it would + // refuse is the whole survey's props blob. Dropping the key is the only safe answer. + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.setEmbeddedData([ + "broken": .number(Double.nan), + "alsoBroken": .number(Double.infinity), + ]) + + assertSnapshot(["plan": "pro"]) + XCTAssertTrue(JSONSerialization.isValidJSONObject(EmbeddedDataManager.shared.snapshot())) + } + + // MARK: - Lifetime + + func testSnapshotIsDetachedFromLaterWrites() { + // What "a value set after a survey is displayed does not change that response" rests on: + // the WebView payload holds this dictionary for the life of the survey. + Formbricks.setEmbeddedData(["plan": "pro"]) + let snapshot = EmbeddedDataManager.shared.snapshot() + + Formbricks.setEmbeddedData(["plan": "enterprise", "extra": "later"]) + + XCTAssertEqual(snapshot as NSDictionary, ["plan": "pro"] as NSDictionary) + } + + func testWorksBeforeSetup() { + // Deliberately unlike the other public methods: a host that pushes context at launch must + // not have the value dropped because initialization had not finished yet. + XCTAssertFalse(Formbricks.isInitialized) + + Formbricks.setEmbeddedData(["plan": "pro"]) + + assertSnapshot(["plan": "pro"]) + } + + func testIsNotPersisted() { + // A cold start begins empty. Nothing host-supplied may reach UserDefaults, where it would + // outlive the session and blur the Embedded Data ↔ contact-attribute boundary. A UUID + // marker so the search cannot collide with unrelated defaults. + let marker = "fb-embedded-probe-\(UUID().uuidString)" + + Formbricks.setEmbeddedData(["probe": .string(marker)]) + + // Only the marker is asserted, deliberately: comparing the whole key set would also catch a + // write from some earlier test's in-flight sync and fail for a reason this test is not about. + let defaults = UserDefaults.standard.dictionaryRepresentation() + XCTAssertFalse(defaults.values.contains { String(describing: $0).contains(marker) }) + } + + func testCleanupClearsTheBag() { + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.cleanup() + + assertSnapshot([:]) + } + + // MARK: - Identity changes + + func testSwitchingUserClearsTheBag() { + seedPersistedUserId("user-a") + setUpSdk() + XCTAssertEqual(Formbricks.userManager?.userId, "user-a") + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.setUserId("user-b") + + assertSnapshot([:]) + } + + func testFirstIdentificationKeepsTheBag() { + // The host pushes context before it knows who the user is — that is the normal order, and + // clearing here would throw away the value the API exists to carry. + setUpSdk() + // `userId` is persisted in UserDefaults, so an id left by an earlier test would make this + // take the switch branch. Log out first — which also empties the bag, hence the ordering. + Formbricks.logout() + XCTAssertNil(Formbricks.userManager?.userId) + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.setUserId("user-a") + + assertSnapshot(["plan": "pro"]) + } + + func testSettingTheSameUserIdKeepsTheBag() { + seedPersistedUserId("user-a") + setUpSdk() + XCTAssertEqual(Formbricks.userManager?.userId, "user-a") + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.setUserId("user-a") + + assertSnapshot(["plan": "pro"]) + } + + func testLogoutClearsTheBag() { + setUpSdk() + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.logout() + + assertSnapshot([:]) + } + + // MARK: - The debug success trace + + func testTheSuccessTraceNamesKeysAndNeverValues() { + // The bag is otherwise invisible — memory-only, no getter — so this trace is a host's only + // confirmation that a write landed. Its one hard rule: the documented use of this bag + // includes hashed identity fields, so a value must never reach a log line. + let message = EmbeddedDataManager.setTrace( + set: ["plan", "hashedEmail"], removed: ["screen"], held: ["hashedEmail", "plan"]) + + XCTAssertTrue(message.contains("set [hashedEmail, plan]")) + XCTAssertTrue(message.contains("removed [screen]")) + XCTAssertTrue(message.contains("the bag now holds [hashedEmail, plan]")) + XCTAssertTrue(message.contains("only if the survey declares them")) + } + + func testTheSuccessTraceOmitsTheRemovedListWhenNothingWasRemoved() { + let message = EmbeddedDataManager.setTrace(set: ["plan"], removed: [], held: ["plan"]) + + XCTAssertFalse(message.contains("removed")) + } + + func testTheSuccessTraceIsStableAcrossCalls() { + // Swift dictionaries iterate in an unspecified order that varies per process, so the trace + // sorts every list. Without that, the same bag would print differently run to run. + let first = EmbeddedDataManager.setTrace( + set: ["b", "a", "c"], removed: [], held: ["c", "a", "b"]) + let second = EmbeddedDataManager.setTrace( + set: ["c", "b", "a"], removed: [], held: ["a", "b", "c"]) + + XCTAssertEqual(first, second) + XCTAssertTrue(first.contains("set [a, b, c]")) + } + + // MARK: - Thread safety + + func testConcurrentWritesDoNotCrash() { + // The host may call from any thread while the main queue reads the snapshot to present a + // survey. Without the serial queue this trips the dictionary's exclusivity checks. + let iterations = 200 + let done = expectation(description: "concurrent writes") + done.expectedFulfillmentCount = iterations + + DispatchQueue.concurrentPerform(iterations: iterations) { index in + Formbricks.setEmbeddedData(["key\(index % 8)": .number(Double(index))]) + _ = EmbeddedDataManager.shared.snapshot() + done.fulfill() + } + + wait(for: [done], timeout: 10) + XCTAssertFalse(EmbeddedDataManager.shared.snapshot().isEmpty) + } +}