From 09497791e0bbcd9fa8c603fb3c786d3956f0490f Mon Sep 17 00:00:00 2001 From: brunol95 Date: Tue, 19 May 2026 15:58:40 -0400 Subject: [PATCH 1/3] deprecate metadata field in user update --- Sources/ClerkKit/Domains/User/User.swift | 59 +++++- .../ClerkKit/Domains/User/UserService.swift | 13 ++ .../User/UserUpdateMetadataParams.swift | 21 +++ .../Domains/User/UserUpdateParams.swift | 69 ++++++- .../Mocks/MockServices/MockUserService.swift | 13 ++ Sources/ClerkKit/Utils/JSON+MergePatch.swift | 53 ++++++ Sources/ClerkKit/Utils/Version.swift | 4 +- .../User/UserUpdateMetadataTests.swift | 98 ++++++++++ .../Domains/User/UserUpdateRoutingTests.swift | 177 ++++++++++++++++++ Tests/Utils/JSONMergePatchTests.swift | 131 +++++++++++++ 10 files changed, 628 insertions(+), 10 deletions(-) create mode 100644 Sources/ClerkKit/Domains/User/UserUpdateMetadataParams.swift create mode 100644 Sources/ClerkKit/Utils/JSON+MergePatch.swift create mode 100644 Tests/Domains/User/UserUpdateMetadataTests.swift create mode 100644 Tests/Domains/User/UserUpdateRoutingTests.swift create mode 100644 Tests/Utils/JSONMergePatchTests.swift diff --git a/Sources/ClerkKit/Domains/User/User.swift b/Sources/ClerkKit/Domains/User/User.swift index 194737146..0124a44eb 100644 --- a/Sources/ClerkKit/Domains/User/User.swift +++ b/Sources/ClerkKit/Domains/User/User.swift @@ -222,9 +222,66 @@ extension User { /// /// For example, if you want to use the `update(.init(firstName:))` method, you must enable the Name setting. /// It can be found in the Email, phone, username > Personal information section in the Clerk Dashboard. + /// + /// When `params.unsafeMetadata` is provided (deprecated), the SDK issues two + /// FAPI calls under the hood — one `PATCH /v1/me` for the other fields, and + /// one `PATCH /v1/me/metadata` carrying a computed merge patch that + /// reproduces *replace* semantics through the deep-merge endpoint. If the + /// first call fails, the metadata is never modified. Callers needing strict + /// atomicity should call ``update(_:)`` (fields only) and + /// ``updateMetadata(_:)`` separately and handle partial failures themselves. @discardableResult @MainActor public func update(_ params: User.UpdateParams) async throws -> User { - try await userService.update(params: params) + let service = userService + + guard let desired = params._unsafeMetadata else { + return try await service.update(params: params) + } + + var rest = params + rest._unsafeMetadata = nil + let afterPatch: User + if rest.hasAnyField { + afterPatch = try await service.update(params: rest) + } else { + afterPatch = self + } + + + let current = self.unsafeMetadata ?? .object([:]) + let patch = current.mergePatch(against: desired) + if case let .object(patchObj) = patch, patchObj.isEmpty { + return afterPatch + } + + return try await service.updateMetadata( + params: User.UpdateMetadataParams(unsafeMetadata: patch) + ) + } + + /// Updates the current user's metadata via `PATCH /v1/me/metadata` with + /// deep-merge semantics: keys in the patch overwrite or extend the current + /// `unsafeMetadata`, and any key whose value is ``JSON/null`` is removed at + /// any nesting level. + /// + /// Prefer this method over passing `unsafeMetadata` to ``update(_:)``, + /// which is deprecated. + /// + /// - Returns: The updated ``User``. + @discardableResult @MainActor + public func updateMetadata(_ params: User.UpdateMetadataParams) async throws -> User { + try await userService.updateMetadata(params: params) + } + + /// Convenience overload of ``updateMetadata(_:)`` that takes the + /// `unsafeMetadata` value directly. + /// + /// - Parameter unsafeMetadata: The metadata patch to merge into the current + /// value. Use ``JSON/null`` for any key whose value should be removed. + /// - Returns: The updated ``User``. + @discardableResult @MainActor + public func updateMetadata(unsafeMetadata: JSON) async throws -> User { + try await updateMetadata(.init(unsafeMetadata: unsafeMetadata)) } /// Generates a fresh new set of backup codes for the user. Every time the method is called, it will replace the previously generated backup codes. diff --git a/Sources/ClerkKit/Domains/User/UserService.swift b/Sources/ClerkKit/Domains/User/UserService.swift index 832dbb5a2..f4ecaafca 100644 --- a/Sources/ClerkKit/Domains/User/UserService.swift +++ b/Sources/ClerkKit/Domains/User/UserService.swift @@ -9,6 +9,7 @@ import Foundation protocol UserServiceProtocol: Sendable { @MainActor func reload() async throws -> User @MainActor func update(params: User.UpdateParams) async throws -> User + @MainActor func updateMetadata(params: User.UpdateMetadataParams) async throws -> User @MainActor func createBackupCodes() async throws -> BackupCodeResource @MainActor func createEmailAddress(emailAddress: String) async throws -> EmailAddress @MainActor func createPhoneNumber(phoneNumber: String) async throws -> PhoneNumber @@ -78,6 +79,18 @@ final class UserService: UserServiceProtocol { return try await apiClient.send(request).value.response } + @MainActor + func updateMetadata(params: User.UpdateMetadataParams) async throws -> User { + let request = Request>( + path: "/v1/me/metadata", + method: .patch, + query: [("_clerk_session_id", value: Clerk.shared.session?.id)], + body: params + ) + + return try await apiClient.send(request).value.response + } + @MainActor func createBackupCodes() async throws -> BackupCodeResource { let request = Request>( diff --git a/Sources/ClerkKit/Domains/User/UserUpdateMetadataParams.swift b/Sources/ClerkKit/Domains/User/UserUpdateMetadataParams.swift new file mode 100644 index 000000000..3a00e8cc7 --- /dev/null +++ b/Sources/ClerkKit/Domains/User/UserUpdateMetadataParams.swift @@ -0,0 +1,21 @@ +// +// UserUpdateMetadataParams.swift +// Clerk +// + +extension User { + /// Parameters for ``User/updateMetadata(_:)``. + /// + /// The submitted value is deep-merged into the existing `unsafeMetadata`: + /// keys in the patch overwrite or extend the current value, and any key + /// whose value is ``JSON/null`` is removed at any nesting level. Omit + /// (`nil`) to make no change. + public struct UpdateMetadataParams: Encodable, Sendable { + public init(unsafeMetadata: JSON? = nil) { + self.unsafeMetadata = unsafeMetadata + } + + /// A JSON object containing the unsafe metadata patch. + public var unsafeMetadata: JSON? + } +} diff --git a/Sources/ClerkKit/Domains/User/UserUpdateParams.swift b/Sources/ClerkKit/Domains/User/UserUpdateParams.swift index 7ff6c9990..96a27c304 100644 --- a/Sources/ClerkKit/Domains/User/UserUpdateParams.swift +++ b/Sources/ClerkKit/Domains/User/UserUpdateParams.swift @@ -5,20 +5,39 @@ extension User { public struct UpdateParams: Encodable, Sendable { + public init( + username: String? = nil, + firstName: String? = nil, + lastName: String? = nil, + primaryEmailAddressId: String? = nil, + primaryPhoneNumberId: String? = nil + ) { + self.username = username + self.firstName = firstName + self.lastName = lastName + self.primaryEmailAddressId = primaryEmailAddressId + self.primaryPhoneNumberId = primaryPhoneNumberId + } + + @available( + *, deprecated, + message: + "Use User.updateMetadata(unsafeMetadata:) for partial updates (deep merge). Passing unsafeMetadata to update(_:) is deprecated and will be removed in a future major version." + ) public init( username: String? = nil, firstName: String? = nil, lastName: String? = nil, primaryEmailAddressId: String? = nil, primaryPhoneNumberId: String? = nil, - unsafeMetadata: JSON? = nil + unsafeMetadata: JSON? ) { self.username = username self.firstName = firstName self.lastName = lastName self.primaryEmailAddressId = primaryEmailAddressId self.primaryPhoneNumberId = primaryPhoneNumberId - self.unsafeMetadata = unsafeMetadata + self._unsafeMetadata = unsafeMetadata } /// The user's username. @@ -36,10 +55,46 @@ extension User { /// The unique identifier for the PhoneNumber that the user has set as primary. public var primaryPhoneNumberId: String? - /** - Metadata that can be read and set from the Frontend API. One common use case for this attribute is to implement custom fields that will be attached to the User object. - Please note that there is also an unsafeMetadata attribute in the SignUp object. The value of that field will be automatically copied to the user's unsafe metadata once the sign up is complete. - */ - public var unsafeMetadata: JSON? + /// Backing storage for the deprecated ``unsafeMetadata`` surface. Used by the + /// routing logic in ``User/update(_:)`` so internal reads do not trigger the + /// deprecation warning attached to the public computed property. + var _unsafeMetadata: JSON? + + /// Metadata that can be read and set from the Frontend API. One common use case + /// for this attribute is to implement custom fields that will be attached to the + /// User object. + @available( + *, deprecated, + message: + "Use User.updateMetadata(unsafeMetadata:) for partial updates (deep merge). Passing unsafeMetadata to update(_:) is deprecated and will be removed in a future major version." + ) + public var unsafeMetadata: JSON? { + get { _unsafeMetadata } + set { _unsafeMetadata = newValue } + } + + private enum CodingKeys: String, CodingKey { + case username + case firstName + case lastName + case primaryEmailAddressId + case primaryPhoneNumberId + case _unsafeMetadata = "unsafeMetadata" + } + } +} + +extension User.UpdateParams { + /// True when any field other than `unsafeMetadata` would appear in the + /// encoded request body. Computed via the actual encoder, so new fields + /// added to the struct are picked up automatically without updating this + /// helper. + var hasAnyField: Bool { + var copy = self + copy._unsafeMetadata = nil + guard let encoded = try? JSON(encodable: copy), + case let .object(obj) = encoded + else { return false } + return !obj.isEmpty } } diff --git a/Sources/ClerkKit/Mocks/MockServices/MockUserService.swift b/Sources/ClerkKit/Mocks/MockServices/MockUserService.swift index 702364b83..c598c2b97 100644 --- a/Sources/ClerkKit/Mocks/MockServices/MockUserService.swift +++ b/Sources/ClerkKit/Mocks/MockServices/MockUserService.swift @@ -21,6 +21,9 @@ package final class MockUserService: UserServiceProtocol { /// Custom handler for the `update(params:)` method. package nonisolated(unsafe) var updateHandler: ((User.UpdateParams) async throws -> User)? + /// Custom handler for the `updateMetadata(params:)` method. + package nonisolated(unsafe) var updateMetadataHandler: ((User.UpdateMetadataParams) async throws -> User)? + /// Custom handler for the `createBackupCodes()` method. package nonisolated(unsafe) var createBackupCodesHandler: (() async throws -> BackupCodeResource)? @@ -132,6 +135,7 @@ package final class MockUserService: UserServiceProtocol { getSessions: ((User) async throws -> [Session])? = nil, reload: (() async throws -> User)? = nil, update: ((User.UpdateParams) async throws -> User)? = nil, + updateMetadata: ((User.UpdateMetadataParams) async throws -> User)? = nil, createBackupCodes: (() async throws -> BackupCodeResource)? = nil, createEmailAddress: ((String) async throws -> EmailAddress)? = nil, createPhoneNumber: ((String) async throws -> PhoneNumber)? = nil, @@ -152,6 +156,7 @@ package final class MockUserService: UserServiceProtocol { getSessionsHandler = getSessions reloadHandler = reload updateHandler = update + updateMetadataHandler = updateMetadata createBackupCodesHandler = createBackupCodes createEmailAddressHandler = createEmailAddress createPhoneNumberHandler = createPhoneNumber @@ -195,6 +200,14 @@ package final class MockUserService: UserServiceProtocol { return .mock } + @MainActor + package func updateMetadata(params: User.UpdateMetadataParams) async throws -> User { + if let handler = updateMetadataHandler { + return try await handler(params) + } + return .mock + } + @MainActor package func createBackupCodes() async throws -> BackupCodeResource { if let handler = createBackupCodesHandler { diff --git a/Sources/ClerkKit/Utils/JSON+MergePatch.swift b/Sources/ClerkKit/Utils/JSON+MergePatch.swift new file mode 100644 index 000000000..1366371cb --- /dev/null +++ b/Sources/ClerkKit/Utils/JSON+MergePatch.swift @@ -0,0 +1,53 @@ +// +// JSON+MergePatch.swift +// Clerk +// + +extension JSON { + /// Computes a JSON Merge Patch (RFC 7396) that, when applied to `self`, + /// produces `desired`. + /// + /// Keys present in `self` but absent from `desired` become ``JSON/null`` + /// in the patch — RFC 7396 null-delete semantics. + /// + /// Used to express *replace* semantics through a merge endpoint: the SDK + /// holds the current resource state locally, the caller passes the + /// desired state, and we send the diff that makes the server side end up + /// at the desired state. + /// + /// Behaviour: + /// - both plain objects: recurse; emit only keys whose value changes + /// - `desired == .null`: returned verbatim (caller decides what null means) + /// - any other type mismatch: `desired` is returned (full replace at that node) + /// - arrays are treated as atomic per RFC 7396 + func mergePatch(against desired: JSON) -> JSON { + if case .null = desired { return .null } + guard case let .object(curObj) = self, + case let .object(desObj) = desired + else { + return desired + } + + var patch: [String: JSON] = [:] + + for (key, des) in desObj { + guard let cur = curObj[key] else { + patch[key] = des + continue + } + if case .object = cur, case .object = des { + let sub = cur.mergePatch(against: des) + if case let .object(subObj) = sub, subObj.isEmpty { continue } + patch[key] = sub + } else if cur != des { + patch[key] = des + } + } + + for key in curObj.keys where desObj[key] == nil { + patch[key] = .null + } + + return .object(patch) + } +} diff --git a/Sources/ClerkKit/Utils/Version.swift b/Sources/ClerkKit/Utils/Version.swift index d8ba2bb90..cb7364bf2 100644 --- a/Sources/ClerkKit/Utils/Version.swift +++ b/Sources/ClerkKit/Utils/Version.swift @@ -5,6 +5,6 @@ import Foundation extension Clerk { - public nonisolated static let sdkVersion: String = "1.1.3" - nonisolated static let apiVersion: String = "2025-11-10" + public nonisolated static let sdkVersion: String = "1.2.0" + nonisolated static let apiVersion: String = "2026-05-12" } diff --git a/Tests/Domains/User/UserUpdateMetadataTests.swift b/Tests/Domains/User/UserUpdateMetadataTests.swift new file mode 100644 index 000000000..b4fcf7e8f --- /dev/null +++ b/Tests/Domains/User/UserUpdateMetadataTests.swift @@ -0,0 +1,98 @@ +@testable import ClerkKit +import ConcurrencyExtras +import Foundation +import Mocker +import Testing + +@MainActor +@Suite(.serialized) +struct UserUpdateMetadataTests { + init() { + configureClerkForTesting() + } + + @Test + func updateMetadataParamsHitsMetadataEndpoint() async throws { + let requestHandled = LockIsolated(false) + let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/metadata")! + + var mock = try Mock( + url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200, + data: [ + .patch: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)), + ] + ) + + mock.onRequestHandler = OnRequestHandler { request in + #expect(request.httpMethod == "PATCH") + #expect(request.url?.path == "/v1/me/metadata") + #expect(request.allHTTPHeaderFields?["Content-Type"] == "application/x-www-form-urlencoded") + #expect(request.urlEncodedFormBody!["unsafe_metadata"] == "{\"theme\":\"dark\"}") + requestHandled.setValue(true) + } + mock.register() + + _ = try await Clerk.shared.dependencies.userService.updateMetadata( + params: .init(unsafeMetadata: ["theme": "dark"]) + ) + #expect(requestHandled.value) + } + + @Test + func jsonObjectOverloadProducesSameRequestShapeAsParamsVariant() async throws { + let captured = LockIsolated(nil) + let service = MockUserService(updateMetadata: { params in + captured.setValue(params) + return .mock + }) + configureService(service) + + _ = try await User.mock.updateMetadata(unsafeMetadata: ["k": "v"]) + + let params = try #require(captured.value) + #expect(params.unsafeMetadata == ["k": "v"]) + } + + @Test + func nullValuesInPatchReachTheWireAsLiteralNull() async throws { + // Critical correctness check: if `ClerkURLEncodedFormEncoderMiddleware` silently + // drops top-level `.null` values when serializing the body, null-deletes never + // reach FAPI and `update({unsafeMetadata})` quietly becomes merge-only. + let requestHandled = LockIsolated(false) + let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/metadata")! + + var mock = try Mock( + url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200, + data: [ + .patch: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)), + ] + ) + + mock.onRequestHandler = OnRequestHandler { request in + let body = request.urlEncodedFormBody!["unsafe_metadata"] + // The stringified JSON must contain a literal `null` value for `removed`. + #expect(body?.contains("\"removed\":null") == true, + "Expected literal null in unsafe_metadata; got: \(body ?? "nil")") + #expect(body?.contains("\"kept\":\"yes\"") == true) + requestHandled.setValue(true) + } + mock.register() + + _ = try await Clerk.shared.dependencies.userService.updateMetadata( + params: .init(unsafeMetadata: ["kept": "yes", "removed": .null]) + ) + #expect(requestHandled.value) + } + + // MARK: - Helpers + + private func configureService(_ service: MockUserService) { + Clerk.shared.dependencies = MockDependencyContainer( + apiClient: createMockAPIClient(), + userService: service + ) + try! (Clerk.shared.dependencies as! MockDependencyContainer) + .configurationManager + .configure(publishableKey: testPublishableKey, options: .init()) + } +} diff --git a/Tests/Domains/User/UserUpdateRoutingTests.swift b/Tests/Domains/User/UserUpdateRoutingTests.swift new file mode 100644 index 000000000..d67aafa71 --- /dev/null +++ b/Tests/Domains/User/UserUpdateRoutingTests.swift @@ -0,0 +1,177 @@ +@testable import ClerkKit +import ConcurrencyExtras +import Foundation +import Testing + +@MainActor +@Suite(.serialized) +struct UserUpdateRoutingTests { + init() { + configureClerkForTesting() + } + + // MARK: - Routing + + @Test + func noMetadataIssuesSingleUpdateCall() async throws { + let order = LockIsolated<[String]>([]) + let updateCalls = LockIsolated(0) + let metadataCalls = LockIsolated(0) + + let service = MockUserService( + update: { _ in + order.withValue { $0.append("update") } + updateCalls.withValue { $0 += 1 } + return .mock + }, + updateMetadata: { _ in + metadataCalls.withValue { $0 += 1 } + return .mock + } + ) + configureService(service) + + _ = try await User.mock.update(.init(firstName: "Jane")) + + #expect(updateCalls.value == 1) + #expect(metadataCalls.value == 0) + #expect(order.value == ["update"]) + } + + @Test + @available(*, deprecated) + func onlyMetadataIssuesSingleUpdateMetadataCallWithComputedPatch() async throws { + let updateCalls = LockIsolated(0) + let captured = LockIsolated(nil) + + let service = MockUserService( + update: { _ in + updateCalls.withValue { $0 += 1 } + return .mock + }, + updateMetadata: { params in + captured.setValue(params.unsafeMetadata) + return .mock + } + ) + configureService(service) + + var user = User.mock + user.unsafeMetadata = ["theme": "dark", "layout": "compact"] + + _ = try await user.update( + .init(unsafeMetadata: ["theme": "light"]) + ) + + #expect(updateCalls.value == 0) + // The patch null-deletes `layout` (absent from desired) and overwrites `theme`. + #expect(captured.value == ["theme": "light", "layout": .null]) + } + + @Test + @available(*, deprecated) + func mixedFieldsAndMetadataIssueUpdateThenUpdateMetadataInOrder() async throws { + let order = LockIsolated<[String]>([]) + let updateParams = LockIsolated(nil) + let metadataPatch = LockIsolated(nil) + + let service = MockUserService( + update: { params in + order.withValue { $0.append("update") } + updateParams.setValue(params) + return .mock + }, + updateMetadata: { params in + order.withValue { $0.append("updateMetadata") } + metadataPatch.setValue(params.unsafeMetadata) + return .mock + } + ) + configureService(service) + + var user = User.mock + user.unsafeMetadata = ["foo": "old"] + + _ = try await user.update( + .init(firstName: "Jane", unsafeMetadata: ["foo": "new", "bar": "added"]) + ) + + #expect(order.value == ["update", "updateMetadata"]) + #expect(updateParams.value?.firstName == "Jane") + // unsafeMetadata must NOT be on the rest params sent to /me. + #expect(updateParams.value?._unsafeMetadata == nil) + #expect(metadataPatch.value == ["foo": "new", "bar": "added"]) + } + + @Test + @available(*, deprecated) + func identicalMetadataShortCircuitsWithoutMetadataCall() async throws { + let updateCalls = LockIsolated(0) + let metadataCalls = LockIsolated(0) + + let service = MockUserService( + update: { _ in + updateCalls.withValue { $0 += 1 } + return .mock + }, + updateMetadata: { _ in + metadataCalls.withValue { $0 += 1 } + return .mock + } + ) + configureService(service) + + var user = User.mock + user.unsafeMetadata = ["theme": "dark"] + + _ = try await user.update(.init(unsafeMetadata: ["theme": "dark"])) + + #expect(updateCalls.value == 0) + #expect(metadataCalls.value == 0) + } + + @Test + @available(*, deprecated) + func identicalMetadataWithOtherFieldsReturnsTheUpdateMeResponse() async throws { + // Receiver has stale firstName; the /me response (User.mock2) has the fresh one. + // The bug to guard against: empty-patch short-circuit returning stale `self` + // instead of the fresh `afterPatch`. + let metadataCalls = LockIsolated(0) + let service = MockUserService( + update: { _ in + var fresh = User.mock + fresh.firstName = "Fresh" + fresh.unsafeMetadata = ["theme": "dark"] + return fresh + }, + updateMetadata: { _ in + metadataCalls.withValue { $0 += 1 } + return .mock + } + ) + configureService(service) + + var user = User.mock + user.firstName = "Stale" + user.unsafeMetadata = ["theme": "dark"] + + let returned = try await user.update( + .init(firstName: "Fresh", unsafeMetadata: ["theme": "dark"]) + ) + + #expect(metadataCalls.value == 0, "Identical metadata must not trigger a /me/metadata call") + #expect(returned.firstName == "Fresh", "Must return the fresh /me response, not stale self") + } + + // MARK: - Helpers + + private func configureService(_ service: MockUserService) { + Clerk.shared.dependencies = MockDependencyContainer( + apiClient: createMockAPIClient(), + userService: service + ) + try! (Clerk.shared.dependencies as! MockDependencyContainer) + .configurationManager + .configure(publishableKey: testPublishableKey, options: .init()) + } +} diff --git a/Tests/Utils/JSONMergePatchTests.swift b/Tests/Utils/JSONMergePatchTests.swift new file mode 100644 index 000000000..21544fedb --- /dev/null +++ b/Tests/Utils/JSONMergePatchTests.swift @@ -0,0 +1,131 @@ +@testable import ClerkKit +import Foundation +import Testing + +@Suite +struct JSONMergePatchTests { + + @Test + func addedKeysAppearVerbatim() { + let current: JSON = ["a": 1] + let desired: JSON = ["a": 1, "b": 2] + #expect(current.mergePatch(against: desired) == ["b": 2]) + } + + @Test + func keysAbsentFromDesiredBecomeNull() { + let current: JSON = ["a": 1, "b": 2] + let desired: JSON = ["a": 1] + #expect(current.mergePatch(against: desired) == ["b": .null]) + } + + @Test + func changedPrimitiveValuesAreOverwritten() { + let current: JSON = ["a": 1] + let desired: JSON = ["a": 2] + #expect(current.mergePatch(against: desired) == ["a": 2]) + } + + @Test + func unchangedValuesAreSkipped() { + let current: JSON = ["a": 1, "b": 2] + let desired: JSON = ["a": 1, "b": 2] + #expect(current.mergePatch(against: desired) == .object([:])) + } + + @Test + func nestedObjectsRecurseAndEmitOnlyChangedSubKeys() { + let current: JSON = ["profile": ["theme": "dark", "font": "sans"]] + let desired: JSON = ["profile": ["theme": "light", "font": "sans"]] + #expect(current.mergePatch(against: desired) == ["profile": ["theme": "light"]]) + } + + @Test + func removedNestedKeyIsNulledSiblingsUntouched() { + let current: JSON = ["profile": ["theme": "dark", "font": "sans"]] + let desired: JSON = ["profile": ["font": "sans"]] + #expect(current.mergePatch(against: desired) == ["profile": ["theme": .null]]) + } + + @Test + func typeMismatchReturnsDesiredVerbatim() { + let current: JSON = ["a": 1] + let desired: JSON = "replaced" + #expect(current.mergePatch(against: desired) == "replaced") + } + + @Test + func nullDesiredIsPassedThroughVerbatim() { + let current: JSON = ["a": 1] + #expect(current.mergePatch(against: .null) == .null) + } + + @Test + func desiredEmptyObjectClearsEveryExistingKey() { + let current: JSON = ["a": 1, "b": 2] + let desired: JSON = .object([:]) + #expect(current.mergePatch(against: desired) == ["a": .null, "b": .null]) + } + + @Test + func emptyCurrentReturnsDesiredVerbatim() { + let current: JSON = .object([:]) + let desired: JSON = ["a": 1, "b": ["c": 2]] + #expect(current.mergePatch(against: desired) == desired) + } + + @Test + func arraysAreTreatedAsAtomic() { + // RFC 7396 explicitly treats arrays as opaque. + let current: JSON = ["tags": ["a", "b"]] + let desired: JSON = ["tags": ["a"]] + #expect(current.mergePatch(against: desired) == ["tags": ["a"]]) + } + + @Test + func applyingThePatchReproducesDesired() { + // Use a local RFC 7396 applier (null deletes) as the oracle. JSON.merging(with:) + // is *not* RFC 7396 — it retains .null values instead of deleting keys. + let current: JSON = [ + "a": 1, + "nested": ["x": 1, "y": 2], + "removed": true, + ] + let desired: JSON = [ + "a": 2, + "nested": ["x": 1, "z": 3], + "added": "yes", + ] + + let patch = current.mergePatch(against: desired) + #expect(applyMergePatch(current, patch) == desired) + if case let .object(patchObj) = patch { + #expect(!patchObj.isEmpty, "Patch must not be empty for a real change") + } else { + Issue.record("Expected the patch to be a JSON object") + } + } + + // MARK: - Helpers + + /// RFC 7396 reference applier: null values delete keys, recursion on objects, + /// non-object patch values fully replace at that node. + private func applyMergePatch(_ target: JSON, _ patch: JSON) -> JSON { + guard case let .object(patchObj) = patch else { return patch } + var out: [String: JSON] + if case let .object(t) = target { + out = t + } else { + out = [:] + } + for (key, value) in patchObj { + if case .null = value { + out.removeValue(forKey: key) + } else { + let nested = out[key] ?? .object([:]) + out[key] = applyMergePatch(nested, value) + } + } + return .object(out) + } +} From 63ef50a4fd9591ba3a03381174cf3de6e0410e78 Mon Sep 17 00:00:00 2001 From: brunol95 Date: Tue, 19 May 2026 16:14:01 -0400 Subject: [PATCH 2/3] address lint formatting --- Sources/ClerkKit/Domains/User/User.swift | 17 ++++++++--------- .../Domains/User/UserUpdateParams.swift | 12 ++++++------ Sources/ClerkKit/Utils/JSON+MergePatch.swift | 2 +- Tests/Utils/JSONMergePatchTests.swift | 14 ++++++-------- 4 files changed, 21 insertions(+), 24 deletions(-) diff --git a/Sources/ClerkKit/Domains/User/User.swift b/Sources/ClerkKit/Domains/User/User.swift index 0124a44eb..11359d697 100644 --- a/Sources/ClerkKit/Domains/User/User.swift +++ b/Sources/ClerkKit/Domains/User/User.swift @@ -240,15 +240,14 @@ extension User { var rest = params rest._unsafeMetadata = nil - let afterPatch: User - if rest.hasAnyField { - afterPatch = try await service.update(params: rest) - } else { - afterPatch = self - } - - - let current = self.unsafeMetadata ?? .object([:]) + let afterPatch: User = + if rest.hasAnyField { + try await service.update(params: rest) + } else { + self + } + + let current = unsafeMetadata ?? .object([:]) let patch = current.mergePatch(against: desired) if case let .object(patchObj) = patch, patchObj.isEmpty { return afterPatch diff --git a/Sources/ClerkKit/Domains/User/UserUpdateParams.swift b/Sources/ClerkKit/Domains/User/UserUpdateParams.swift index 96a27c304..4debfd8e7 100644 --- a/Sources/ClerkKit/Domains/User/UserUpdateParams.swift +++ b/Sources/ClerkKit/Domains/User/UserUpdateParams.swift @@ -21,8 +21,7 @@ extension User { @available( *, deprecated, - message: - "Use User.updateMetadata(unsafeMetadata:) for partial updates (deep merge). Passing unsafeMetadata to update(_:) is deprecated and will be removed in a future major version." + message: "Use User.updateMetadata(unsafeMetadata:) for partial updates (deep merge). Passing unsafeMetadata to update(_:) is deprecated and will be removed in a future major version." ) public init( username: String? = nil, @@ -37,7 +36,7 @@ extension User { self.lastName = lastName self.primaryEmailAddressId = primaryEmailAddressId self.primaryPhoneNumberId = primaryPhoneNumberId - self._unsafeMetadata = unsafeMetadata + _unsafeMetadata = unsafeMetadata } /// The user's username. @@ -58,6 +57,7 @@ extension User { /// Backing storage for the deprecated ``unsafeMetadata`` surface. Used by the /// routing logic in ``User/update(_:)`` so internal reads do not trigger the /// deprecation warning attached to the public computed property. + // swiftlint:disable:next identifier_name var _unsafeMetadata: JSON? /// Metadata that can be read and set from the Frontend API. One common use case @@ -65,8 +65,7 @@ extension User { /// User object. @available( *, deprecated, - message: - "Use User.updateMetadata(unsafeMetadata:) for partial updates (deep merge). Passing unsafeMetadata to update(_:) is deprecated and will be removed in a future major version." + message: "Use User.updateMetadata(unsafeMetadata:) for partial updates (deep merge). Passing unsafeMetadata to update(_:) is deprecated and will be removed in a future major version." ) public var unsafeMetadata: JSON? { get { _unsafeMetadata } @@ -79,6 +78,7 @@ extension User { case lastName case primaryEmailAddressId case primaryPhoneNumberId + // swiftlint:disable:next identifier_name case _unsafeMetadata = "unsafeMetadata" } } @@ -93,7 +93,7 @@ extension User.UpdateParams { var copy = self copy._unsafeMetadata = nil guard let encoded = try? JSON(encodable: copy), - case let .object(obj) = encoded + case let .object(obj) = encoded else { return false } return !obj.isEmpty } diff --git a/Sources/ClerkKit/Utils/JSON+MergePatch.swift b/Sources/ClerkKit/Utils/JSON+MergePatch.swift index 1366371cb..f86826143 100644 --- a/Sources/ClerkKit/Utils/JSON+MergePatch.swift +++ b/Sources/ClerkKit/Utils/JSON+MergePatch.swift @@ -23,7 +23,7 @@ extension JSON { func mergePatch(against desired: JSON) -> JSON { if case .null = desired { return .null } guard case let .object(curObj) = self, - case let .object(desObj) = desired + case let .object(desObj) = desired else { return desired } diff --git a/Tests/Utils/JSONMergePatchTests.swift b/Tests/Utils/JSONMergePatchTests.swift index 21544fedb..52722c93c 100644 --- a/Tests/Utils/JSONMergePatchTests.swift +++ b/Tests/Utils/JSONMergePatchTests.swift @@ -2,9 +2,7 @@ import Foundation import Testing -@Suite struct JSONMergePatchTests { - @Test func addedKeysAppearVerbatim() { let current: JSON = ["a": 1] @@ -112,12 +110,12 @@ struct JSONMergePatchTests { /// non-object patch values fully replace at that node. private func applyMergePatch(_ target: JSON, _ patch: JSON) -> JSON { guard case let .object(patchObj) = patch else { return patch } - var out: [String: JSON] - if case let .object(t) = target { - out = t - } else { - out = [:] - } + var out: [String: JSON] = + if case let .object(t) = target { + t + } else { + [:] + } for (key, value) in patchObj { if case .null = value { out.removeValue(forKey: key) From 8f2e64ca7b32a501023b73c0194e5705c0591b00 Mon Sep 17 00:00:00 2001 From: seanperez Date: Wed, 20 May 2026 11:02:34 -0400 Subject: [PATCH 3/3] Route unsafe metadata updates via metadata API --- Sources/ClerkKit/Domains/User/User.swift | 55 +++--- .../User/UserUpdateMetadataParams.swift | 21 --- .../Domains/User/UserUpdateParams.swift | 51 +++-- .../Mocks/MockServices/MockUserService.swift | 1 + Sources/ClerkKit/Utils/JSON+MergePatch.swift | 59 +++--- Tests/Domains/User/UserServiceTests.swift | 133 ++++++++++++- Tests/Domains/User/UserTests.swift | 170 +++++++++++++++++ .../User/UserUpdateMetadataTests.swift | 98 ---------- .../Domains/User/UserUpdateRoutingTests.swift | 177 ------------------ Tests/Utils/JSONMergePatchTests.swift | 129 ------------- Tests/Utils/JSONUtilitiesTests.swift | 104 ++++++++++ Tests/Utils/URLEncodedFormEncoderTests.swift | 20 ++ 12 files changed, 502 insertions(+), 516 deletions(-) delete mode 100644 Sources/ClerkKit/Domains/User/UserUpdateMetadataParams.swift delete mode 100644 Tests/Domains/User/UserUpdateMetadataTests.swift delete mode 100644 Tests/Domains/User/UserUpdateRoutingTests.swift delete mode 100644 Tests/Utils/JSONMergePatchTests.swift diff --git a/Sources/ClerkKit/Domains/User/User.swift b/Sources/ClerkKit/Domains/User/User.swift index 11359d697..6124e06c5 100644 --- a/Sources/ClerkKit/Domains/User/User.swift +++ b/Sources/ClerkKit/Domains/User/User.swift @@ -223,61 +223,48 @@ extension User { /// For example, if you want to use the `update(.init(firstName:))` method, you must enable the Name setting. /// It can be found in the Email, phone, username > Personal information section in the Clerk Dashboard. /// - /// When `params.unsafeMetadata` is provided (deprecated), the SDK issues two - /// FAPI calls under the hood — one `PATCH /v1/me` for the other fields, and - /// one `PATCH /v1/me/metadata` carrying a computed merge patch that - /// reproduces *replace* semantics through the deep-merge endpoint. If the - /// first call fails, the metadata is never modified. Callers needing strict - /// atomicity should call ``update(_:)`` (fields only) and - /// ``updateMetadata(_:)`` separately and handle partial failures themselves. + /// - Important: Passing `unsafeMetadata` through ``User/UpdateParams`` is deprecated. + /// When `unsafeMetadata` is provided with other profile fields, the SDK sends two + /// requests: one to update profile fields and one to update metadata. These requests + /// are not atomic; if the metadata request fails, profile changes may already be saved. + /// Prefer ``updateMetadata(unsafeMetadata:)`` for metadata updates. @discardableResult @MainActor public func update(_ params: User.UpdateParams) async throws -> User { let service = userService - guard let desired = params._unsafeMetadata else { + guard let desiredUnsafeMetadata = params.deprecatedUnsafeMetadata else { return try await service.update(params: params) } - var rest = params - rest._unsafeMetadata = nil - let afterPatch: User = - if rest.hasAnyField { - try await service.update(params: rest) + let paramsWithoutUnsafeMetadata = params.withoutUnsafeMetadata + let hasProfileUpdates = paramsWithoutUnsafeMetadata.hasAnyField + let userAfterProfileUpdate: User = + if hasProfileUpdates { + try await service.update(params: paramsWithoutUnsafeMetadata) } else { - self + try await service.reload() } - let current = unsafeMetadata ?? .object([:]) - let patch = current.mergePatch(against: desired) - if case let .object(patchObj) = patch, patchObj.isEmpty { - return afterPatch + let currentUnsafeMetadata = userAfterProfileUpdate.unsafeMetadata ?? .object([:]) + let patch = currentUnsafeMetadata.mergePatch(against: desiredUnsafeMetadata) + if case let .object(patchObject) = patch, patchObject.isEmpty { + return userAfterProfileUpdate } - return try await service.updateMetadata( - params: User.UpdateMetadataParams(unsafeMetadata: patch) - ) + return try await service.updateMetadata(params: .init(unsafeMetadata: patch)) } - /// Updates the current user's metadata via `PATCH /v1/me/metadata` with - /// deep-merge semantics: keys in the patch overwrite or extend the current - /// `unsafeMetadata`, and any key whose value is ``JSON/null`` is removed at - /// any nesting level. - /// - /// Prefer this method over passing `unsafeMetadata` to ``update(_:)``, - /// which is deprecated. + /// Updates the user's unsafe metadata. /// - /// - Returns: The updated ``User``. + /// Values are merged into the existing unsafe metadata. Set a key to `JSON.null` to remove it. @discardableResult @MainActor public func updateMetadata(_ params: User.UpdateMetadataParams) async throws -> User { try await userService.updateMetadata(params: params) } - /// Convenience overload of ``updateMetadata(_:)`` that takes the - /// `unsafeMetadata` value directly. + /// Updates the user's unsafe metadata. /// - /// - Parameter unsafeMetadata: The metadata patch to merge into the current - /// value. Use ``JSON/null`` for any key whose value should be removed. - /// - Returns: The updated ``User``. + /// Values are merged into the existing unsafe metadata. Set a key to `JSON.null` to remove it. @discardableResult @MainActor public func updateMetadata(unsafeMetadata: JSON) async throws -> User { try await updateMetadata(.init(unsafeMetadata: unsafeMetadata)) diff --git a/Sources/ClerkKit/Domains/User/UserUpdateMetadataParams.swift b/Sources/ClerkKit/Domains/User/UserUpdateMetadataParams.swift deleted file mode 100644 index 3a00e8cc7..000000000 --- a/Sources/ClerkKit/Domains/User/UserUpdateMetadataParams.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// UserUpdateMetadataParams.swift -// Clerk -// - -extension User { - /// Parameters for ``User/updateMetadata(_:)``. - /// - /// The submitted value is deep-merged into the existing `unsafeMetadata`: - /// keys in the patch overwrite or extend the current value, and any key - /// whose value is ``JSON/null`` is removed at any nesting level. Omit - /// (`nil`) to make no change. - public struct UpdateMetadataParams: Encodable, Sendable { - public init(unsafeMetadata: JSON? = nil) { - self.unsafeMetadata = unsafeMetadata - } - - /// A JSON object containing the unsafe metadata patch. - public var unsafeMetadata: JSON? - } -} diff --git a/Sources/ClerkKit/Domains/User/UserUpdateParams.swift b/Sources/ClerkKit/Domains/User/UserUpdateParams.swift index 4debfd8e7..3b02f881f 100644 --- a/Sources/ClerkKit/Domains/User/UserUpdateParams.swift +++ b/Sources/ClerkKit/Domains/User/UserUpdateParams.swift @@ -3,6 +3,8 @@ // Clerk // +import Foundation + extension User { public struct UpdateParams: Encodable, Sendable { public init( @@ -21,7 +23,7 @@ extension User { @available( *, deprecated, - message: "Use User.updateMetadata(unsafeMetadata:) for partial updates (deep merge). Passing unsafeMetadata to update(_:) is deprecated and will be removed in a future major version." + message: "Use User.updateMetadata(unsafeMetadata:) for metadata updates. Passing unsafeMetadata to update(_:) will be removed in a future major version." ) public init( username: String? = nil, @@ -36,7 +38,7 @@ extension User { self.lastName = lastName self.primaryEmailAddressId = primaryEmailAddressId self.primaryPhoneNumberId = primaryPhoneNumberId - _unsafeMetadata = unsafeMetadata + deprecatedUnsafeMetadata = unsafeMetadata } /// The user's username. @@ -54,22 +56,18 @@ extension User { /// The unique identifier for the PhoneNumber that the user has set as primary. public var primaryPhoneNumberId: String? - /// Backing storage for the deprecated ``unsafeMetadata`` surface. Used by the - /// routing logic in ``User/update(_:)`` so internal reads do not trigger the - /// deprecation warning attached to the public computed property. - // swiftlint:disable:next identifier_name - var _unsafeMetadata: JSON? + var deprecatedUnsafeMetadata: JSON? /// Metadata that can be read and set from the Frontend API. One common use case /// for this attribute is to implement custom fields that will be attached to the /// User object. @available( *, deprecated, - message: "Use User.updateMetadata(unsafeMetadata:) for partial updates (deep merge). Passing unsafeMetadata to update(_:) is deprecated and will be removed in a future major version." + message: "Use User.updateMetadata(unsafeMetadata:) for metadata updates. Passing unsafeMetadata to update(_:) will be removed in a future major version." ) public var unsafeMetadata: JSON? { - get { _unsafeMetadata } - set { _unsafeMetadata = newValue } + get { deprecatedUnsafeMetadata } + set { deprecatedUnsafeMetadata = newValue } } private enum CodingKeys: String, CodingKey { @@ -78,23 +76,36 @@ extension User { case lastName case primaryEmailAddressId case primaryPhoneNumberId - // swiftlint:disable:next identifier_name - case _unsafeMetadata = "unsafeMetadata" + case deprecatedUnsafeMetadata = "unsafeMetadata" } } + + public struct UpdateMetadataParams: Encodable, Sendable { + public init(unsafeMetadata: JSON) { + self.unsafeMetadata = unsafeMetadata + } + + /// The unsafe metadata patch to merge into the current value. + public var unsafeMetadata: JSON + } } extension User.UpdateParams { - /// True when any field other than `unsafeMetadata` would appear in the - /// encoded request body. Computed via the actual encoder, so new fields - /// added to the struct are picked up automatically without updating this - /// helper. var hasAnyField: Bool { var copy = self - copy._unsafeMetadata = nil - guard let encoded = try? JSON(encodable: copy), - case let .object(obj) = encoded + copy.deprecatedUnsafeMetadata = nil + + guard let data = try? JSONEncoder.clerkEncoder.encode(copy), + let encoded = try? JSONDecoder.clerkDecoder.decode(JSON.self, from: data), + case let .object(object) = encoded else { return false } - return !obj.isEmpty + + return !object.isEmpty + } + + var withoutUnsafeMetadata: Self { + var copy = self + copy.deprecatedUnsafeMetadata = nil + return copy } } diff --git a/Sources/ClerkKit/Mocks/MockServices/MockUserService.swift b/Sources/ClerkKit/Mocks/MockServices/MockUserService.swift index c598c2b97..1912df0d0 100644 --- a/Sources/ClerkKit/Mocks/MockServices/MockUserService.swift +++ b/Sources/ClerkKit/Mocks/MockServices/MockUserService.swift @@ -94,6 +94,7 @@ package final class MockUserService: UserServiceProtocol { /// - getSessions: Optional implementation of the `getSessions(user:)` method. /// - reload: Optional implementation of the `reload()` method. /// - update: Optional implementation of the `update(params:)` method. + /// - updateMetadata: Optional implementation of the `updateMetadata(params:)` method. /// - createBackupCodes: Optional implementation of the `createBackupCodes()` method. /// - createEmailAddress: Optional implementation of the `createEmailAddress(emailAddress:)` method. /// - createPhoneNumber: Optional implementation of the `createPhoneNumber(phoneNumber:)` method. diff --git a/Sources/ClerkKit/Utils/JSON+MergePatch.swift b/Sources/ClerkKit/Utils/JSON+MergePatch.swift index f86826143..13758d0fc 100644 --- a/Sources/ClerkKit/Utils/JSON+MergePatch.swift +++ b/Sources/ClerkKit/Utils/JSON+MergePatch.swift @@ -4,50 +4,47 @@ // extension JSON { - /// Computes a JSON Merge Patch (RFC 7396) that, when applied to `self`, - /// produces `desired`. - /// - /// Keys present in `self` but absent from `desired` become ``JSON/null`` - /// in the patch — RFC 7396 null-delete semantics. - /// - /// Used to express *replace* semantics through a merge endpoint: the SDK - /// holds the current resource state locally, the caller passes the - /// desired state, and we send the diff that makes the server side end up - /// at the desired state. - /// - /// Behaviour: - /// - both plain objects: recurse; emit only keys whose value changes - /// - `desired == .null`: returned verbatim (caller decides what null means) - /// - any other type mismatch: `desired` is returned (full replace at that node) - /// - arrays are treated as atomic per RFC 7396 func mergePatch(against desired: JSON) -> JSON { - if case .null = desired { return .null } - guard case let .object(curObj) = self, - case let .object(desObj) = desired - else { + guard case let .object(desiredObject) = desired else { return desired } + guard case let .object(currentObject) = self else { + return desired + } + + return .object(Self.mergePatch(from: currentObject, to: desiredObject)) + } + + private static func mergePatch( + from current: [String: JSON], + to desired: [String: JSON] + ) -> [String: JSON] { var patch: [String: JSON] = [:] - for (key, des) in desObj { - guard let cur = curObj[key] else { - patch[key] = des + for (key, desiredValue) in desired { + guard let currentValue = current[key] else { + patch[key] = desiredValue continue } - if case .object = cur, case .object = des { - let sub = cur.mergePatch(against: des) - if case let .object(subObj) = sub, subObj.isEmpty { continue } - patch[key] = sub - } else if cur != des { - patch[key] = des + + if currentValue == desiredValue { + continue + } + + if case let .object(currentObject) = currentValue, + case let .object(desiredObject) = desiredValue + { + patch[key] = .object(mergePatch(from: currentObject, to: desiredObject)) + } else { + patch[key] = desiredValue } } - for key in curObj.keys where desObj[key] == nil { + for key in current.keys where desired[key] == nil { patch[key] = .null } - return .object(patch) + return patch } } diff --git a/Tests/Domains/User/UserServiceTests.swift b/Tests/Domains/User/UserServiceTests.swift index e34d13c61..2e7cc0ff3 100644 --- a/Tests/Domains/User/UserServiceTests.swift +++ b/Tests/Domains/User/UserServiceTests.swift @@ -59,9 +59,52 @@ struct UserServiceTests { } @Test - func updateWithUnsafeMetadataObjectEncodesMetadataAsJSONString() async throws { + @available(*, deprecated) + func updateWithUnsafeMetadataObjectUsesMetadataEndpoint() async throws { + let reloadRequestHandled = LockIsolated(false) let requestHandled = LockIsolated(false) - let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me")! + let reloadURL = URL(string: mockBaseUrl.absoluteString + "/v1/me")! + let metadataURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/metadata")! + + var reloadMock = try Mock( + url: reloadURL, ignoreQuery: true, contentType: .json, statusCode: 200, + data: [ + .get: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)), + ] + ) + + reloadMock.onRequestHandler = OnRequestHandler { request in + #expect(request.httpMethod == "GET") + reloadRequestHandled.setValue(true) + } + reloadMock.register() + + var metadataMock = try Mock( + url: metadataURL, ignoreQuery: true, contentType: .json, statusCode: 200, + data: [ + .patch: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)), + ] + ) + + metadataMock.onRequestHandler = OnRequestHandler { request in + #expect(request.httpMethod == "PATCH") + #expect(request.allHTTPHeaderFields?["Content-Type"] == "application/x-www-form-urlencoded") + #expect(Self.unsafeMetadataJSON(from: request) == ["token": "some-value"]) + #expect(request.urlEncodedFormBody!["unsafe_metadata[token]"] == nil) + requestHandled.setValue(true) + } + metadataMock.register() + + let metadata: JSON = ["token": "some-value"] + _ = try await User.mock.update(.init(unsafeMetadata: metadata)) + #expect(reloadRequestHandled.value) + #expect(requestHandled.value) + } + + @Test + func updateMetadataUsesMetadataEndpoint() async throws { + let requestHandled = LockIsolated(false) + let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/metadata")! var mock = try Mock( url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200, @@ -73,17 +116,86 @@ struct UserServiceTests { mock.onRequestHandler = OnRequestHandler { request in #expect(request.httpMethod == "PATCH") #expect(request.allHTTPHeaderFields?["Content-Type"] == "application/x-www-form-urlencoded") - #expect(request.urlEncodedFormBody!["unsafe_metadata"] == "{\"token\":\"some-value\"}") - #expect(request.urlEncodedFormBody!["unsafe_metadata[token]"] == nil) + #expect(Self.unsafeMetadataJSON(from: request) == ["token": "some-value"]) requestHandled.setValue(true) } mock.register() - let metadata: JSON = ["token": "some-value"] - _ = try await Clerk.shared.dependencies.userService.update(params: .init(unsafeMetadata: metadata)) + _ = try await Clerk.shared.dependencies.userService.updateMetadata( + params: .init(unsafeMetadata: ["token": "some-value"]) + ) #expect(requestHandled.value) } + @Test + @available(*, deprecated) + func updateWithProfileFieldsAndUnsafeMetadataSplitsRequestsAndPreservesReplacementSemantics() async throws { + let profileRequestHandled = LockIsolated(false) + let metadataRequestHandled = LockIsolated(false) + let profileURL = URL(string: mockBaseUrl.absoluteString + "/v1/me")! + let metadataURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/metadata")! + + var user = User.mock + user.unsafeMetadata = [ + "token": "old-value", + "stale": true, + "nested": [ + "keep": "same", + "remove": "old", + ], + ] + + var profileMock = try Mock( + url: profileURL, ignoreQuery: true, contentType: .json, statusCode: 200, + data: [ + .patch: JSONEncoder.clerkEncoder.encode(ClientResponse(response: user, client: .mock)), + ] + ) + + profileMock.onRequestHandler = OnRequestHandler { request in + #expect(request.httpMethod == "PATCH") + #expect(request.urlEncodedFormBody?["first_name"] == "John") + #expect(request.urlEncodedFormBody?["unsafe_metadata"] == nil) + profileRequestHandled.setValue(true) + } + profileMock.register() + + var metadataMock = try Mock( + url: metadataURL, ignoreQuery: true, contentType: .json, statusCode: 200, + data: [ + .patch: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)), + ] + ) + + metadataMock.onRequestHandler = OnRequestHandler { request in + #expect(request.httpMethod == "PATCH") + #expect(Self.unsafeMetadataJSON(from: request) == [ + "token": "new-value", + "stale": .null, + "nested": [ + "added": "new", + "remove": .null, + ], + ]) + metadataRequestHandled.setValue(true) + } + metadataMock.register() + + _ = try await user.update(.init( + firstName: "John", + unsafeMetadata: [ + "token": "new-value", + "nested": [ + "keep": "same", + "added": "new", + ], + ] + )) + + #expect(profileRequestHandled.value) + #expect(metadataRequestHandled.value) + } + @Test func testCreateBackupCodes() async throws { let requestHandled = LockIsolated(false) @@ -685,4 +797,13 @@ struct UserServiceTests { _ = try await Clerk.shared.dependencies.userService.delete() #expect(requestHandled.value) } + + private static func unsafeMetadataJSON(from request: URLRequest) -> JSON? { + guard let unsafeMetadata = request.urlEncodedFormBody?["unsafe_metadata"], + let data = unsafeMetadata.data(using: .utf8) + else { + return nil + } + return try? JSONDecoder.clerkDecoder.decode(JSON.self, from: data) + } } diff --git a/Tests/Domains/User/UserTests.swift b/Tests/Domains/User/UserTests.swift index b0ca5a412..36a01f6a8 100644 --- a/Tests/Domains/User/UserTests.swift +++ b/Tests/Domains/User/UserTests.swift @@ -52,6 +52,176 @@ struct UserTests { #expect(params.lastName == "Doe") } + @Test + func updateMetadataUsesUserServiceUpdateMetadata() async throws { + let captured = LockIsolated(nil) + let service = MockUserService(updateMetadata: { params in + captured.setValue(params) + return .mock + }) + + configureService(service) + + _ = try await User.mock.updateMetadata(unsafeMetadata: ["token": "some-value"]) + + #expect(captured.value?.unsafeMetadata == ["token": "some-value"]) + } + + @Test + @available(*, deprecated) + func updateWithIdenticalUnsafeMetadataReloadsAndDoesNotCallUpdateMetadata() async throws { + let reloadCalls = LockIsolated(0) + let updateCalls = LockIsolated(0) + let metadataCalls = LockIsolated(0) + let service = MockUserService( + reload: { + reloadCalls.withValue { $0 += 1 } + var user = User.mock + user.unsafeMetadata = ["token": "some-value"] + return user + }, + update: { _ in + updateCalls.withValue { $0 += 1 } + return .mock + }, + updateMetadata: { _ in + metadataCalls.withValue { $0 += 1 } + return .mock + } + ) + + configureService(service) + + var user = User.mock + user.unsafeMetadata = ["token": "some-value"] + + _ = try await user.update(.init(unsafeMetadata: ["token": "some-value"])) + + #expect(reloadCalls.value == 1) + #expect(updateCalls.value == 0) + #expect(metadataCalls.value == 0) + } + + @Test + @available(*, deprecated) + func metadataOnlyDeprecatedUpdateUsesReloadedUnsafeMetadataForReplacementPatch() async throws { + let reloadCalls = LockIsolated(0) + let updateCalls = LockIsolated(0) + let captured = LockIsolated(nil) + let service = MockUserService( + reload: { + reloadCalls.withValue { $0 += 1 } + var user = User.mock + user.unsafeMetadata = [ + "token": "old-value", + "serverOnly": true, + "nested": [ + "keep": "same", + "remove": "old", + ], + ] + return user + }, + update: { _ in + updateCalls.withValue { $0 += 1 } + return .mock + }, + updateMetadata: { params in + captured.setValue(params) + return .mock + } + ) + + configureService(service) + + var user = User.mock + user.unsafeMetadata = ["token": "stale-local-value"] + + _ = try await user.update(.init(unsafeMetadata: [ + "token": "new-value", + "nested": [ + "keep": "same", + "added": "new", + ], + ])) + + #expect(reloadCalls.value == 1) + #expect(updateCalls.value == 0) + #expect(captured.value?.unsafeMetadata == [ + "token": "new-value", + "serverOnly": .null, + "nested": [ + "added": "new", + "remove": .null, + ], + ]) + } + + @Test + @available(*, deprecated) + func metadataOnlyDeprecatedUpdateTreatsReloadedNilUnsafeMetadataAsEmpty() async throws { + let captured = LockIsolated(nil) + let service = MockUserService( + reload: { + var user = User.mock + user.unsafeMetadata = nil + return user + }, + updateMetadata: { params in + captured.setValue(params) + return .mock + } + ) + + configureService(service) + + var user = User.mock + user.unsafeMetadata = ["staleLocal": true] + + _ = try await user.update(.init(unsafeMetadata: ["token": "some-value"])) + + #expect(captured.value?.unsafeMetadata == ["token": "some-value"]) + } + + @Test + @available(*, deprecated) + func profileAndDeprecatedMetadataUpdateTreatsProfileResponseNilUnsafeMetadataAsEmpty() async throws { + let reloadCalls = LockIsolated(0) + let updateCalls = LockIsolated(0) + let captured = LockIsolated(nil) + let service = MockUserService( + reload: { + reloadCalls.withValue { $0 += 1 } + return .mock + }, + update: { params in + updateCalls.withValue { $0 += 1 } + #expect(params.firstName == "John") + var user = User.mock + user.unsafeMetadata = nil + return user + }, + updateMetadata: { params in + captured.setValue(params) + return .mock + } + ) + + configureService(service) + + var user = User.mock + user.unsafeMetadata = ["staleLocal": true] + + _ = try await user.update(.init( + firstName: "John", + unsafeMetadata: ["token": "some-value"] + )) + + #expect(reloadCalls.value == 0) + #expect(updateCalls.value == 1) + #expect(captured.value?.unsafeMetadata == ["token": "some-value"]) + } + @Test func createBackupCodesUsesUserServiceCreateBackupCodes() async throws { let called = LockIsolated(false) diff --git a/Tests/Domains/User/UserUpdateMetadataTests.swift b/Tests/Domains/User/UserUpdateMetadataTests.swift deleted file mode 100644 index b4fcf7e8f..000000000 --- a/Tests/Domains/User/UserUpdateMetadataTests.swift +++ /dev/null @@ -1,98 +0,0 @@ -@testable import ClerkKit -import ConcurrencyExtras -import Foundation -import Mocker -import Testing - -@MainActor -@Suite(.serialized) -struct UserUpdateMetadataTests { - init() { - configureClerkForTesting() - } - - @Test - func updateMetadataParamsHitsMetadataEndpoint() async throws { - let requestHandled = LockIsolated(false) - let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/metadata")! - - var mock = try Mock( - url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200, - data: [ - .patch: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)), - ] - ) - - mock.onRequestHandler = OnRequestHandler { request in - #expect(request.httpMethod == "PATCH") - #expect(request.url?.path == "/v1/me/metadata") - #expect(request.allHTTPHeaderFields?["Content-Type"] == "application/x-www-form-urlencoded") - #expect(request.urlEncodedFormBody!["unsafe_metadata"] == "{\"theme\":\"dark\"}") - requestHandled.setValue(true) - } - mock.register() - - _ = try await Clerk.shared.dependencies.userService.updateMetadata( - params: .init(unsafeMetadata: ["theme": "dark"]) - ) - #expect(requestHandled.value) - } - - @Test - func jsonObjectOverloadProducesSameRequestShapeAsParamsVariant() async throws { - let captured = LockIsolated(nil) - let service = MockUserService(updateMetadata: { params in - captured.setValue(params) - return .mock - }) - configureService(service) - - _ = try await User.mock.updateMetadata(unsafeMetadata: ["k": "v"]) - - let params = try #require(captured.value) - #expect(params.unsafeMetadata == ["k": "v"]) - } - - @Test - func nullValuesInPatchReachTheWireAsLiteralNull() async throws { - // Critical correctness check: if `ClerkURLEncodedFormEncoderMiddleware` silently - // drops top-level `.null` values when serializing the body, null-deletes never - // reach FAPI and `update({unsafeMetadata})` quietly becomes merge-only. - let requestHandled = LockIsolated(false) - let originalURL = URL(string: mockBaseUrl.absoluteString + "/v1/me/metadata")! - - var mock = try Mock( - url: originalURL, ignoreQuery: true, contentType: .json, statusCode: 200, - data: [ - .patch: JSONEncoder.clerkEncoder.encode(ClientResponse(response: .mock, client: .mock)), - ] - ) - - mock.onRequestHandler = OnRequestHandler { request in - let body = request.urlEncodedFormBody!["unsafe_metadata"] - // The stringified JSON must contain a literal `null` value for `removed`. - #expect(body?.contains("\"removed\":null") == true, - "Expected literal null in unsafe_metadata; got: \(body ?? "nil")") - #expect(body?.contains("\"kept\":\"yes\"") == true) - requestHandled.setValue(true) - } - mock.register() - - _ = try await Clerk.shared.dependencies.userService.updateMetadata( - params: .init(unsafeMetadata: ["kept": "yes", "removed": .null]) - ) - #expect(requestHandled.value) - } - - // MARK: - Helpers - - private func configureService(_ service: MockUserService) { - Clerk.shared.dependencies = MockDependencyContainer( - apiClient: createMockAPIClient(), - userService: service - ) - try! (Clerk.shared.dependencies as! MockDependencyContainer) - .configurationManager - .configure(publishableKey: testPublishableKey, options: .init()) - } -} diff --git a/Tests/Domains/User/UserUpdateRoutingTests.swift b/Tests/Domains/User/UserUpdateRoutingTests.swift deleted file mode 100644 index d67aafa71..000000000 --- a/Tests/Domains/User/UserUpdateRoutingTests.swift +++ /dev/null @@ -1,177 +0,0 @@ -@testable import ClerkKit -import ConcurrencyExtras -import Foundation -import Testing - -@MainActor -@Suite(.serialized) -struct UserUpdateRoutingTests { - init() { - configureClerkForTesting() - } - - // MARK: - Routing - - @Test - func noMetadataIssuesSingleUpdateCall() async throws { - let order = LockIsolated<[String]>([]) - let updateCalls = LockIsolated(0) - let metadataCalls = LockIsolated(0) - - let service = MockUserService( - update: { _ in - order.withValue { $0.append("update") } - updateCalls.withValue { $0 += 1 } - return .mock - }, - updateMetadata: { _ in - metadataCalls.withValue { $0 += 1 } - return .mock - } - ) - configureService(service) - - _ = try await User.mock.update(.init(firstName: "Jane")) - - #expect(updateCalls.value == 1) - #expect(metadataCalls.value == 0) - #expect(order.value == ["update"]) - } - - @Test - @available(*, deprecated) - func onlyMetadataIssuesSingleUpdateMetadataCallWithComputedPatch() async throws { - let updateCalls = LockIsolated(0) - let captured = LockIsolated(nil) - - let service = MockUserService( - update: { _ in - updateCalls.withValue { $0 += 1 } - return .mock - }, - updateMetadata: { params in - captured.setValue(params.unsafeMetadata) - return .mock - } - ) - configureService(service) - - var user = User.mock - user.unsafeMetadata = ["theme": "dark", "layout": "compact"] - - _ = try await user.update( - .init(unsafeMetadata: ["theme": "light"]) - ) - - #expect(updateCalls.value == 0) - // The patch null-deletes `layout` (absent from desired) and overwrites `theme`. - #expect(captured.value == ["theme": "light", "layout": .null]) - } - - @Test - @available(*, deprecated) - func mixedFieldsAndMetadataIssueUpdateThenUpdateMetadataInOrder() async throws { - let order = LockIsolated<[String]>([]) - let updateParams = LockIsolated(nil) - let metadataPatch = LockIsolated(nil) - - let service = MockUserService( - update: { params in - order.withValue { $0.append("update") } - updateParams.setValue(params) - return .mock - }, - updateMetadata: { params in - order.withValue { $0.append("updateMetadata") } - metadataPatch.setValue(params.unsafeMetadata) - return .mock - } - ) - configureService(service) - - var user = User.mock - user.unsafeMetadata = ["foo": "old"] - - _ = try await user.update( - .init(firstName: "Jane", unsafeMetadata: ["foo": "new", "bar": "added"]) - ) - - #expect(order.value == ["update", "updateMetadata"]) - #expect(updateParams.value?.firstName == "Jane") - // unsafeMetadata must NOT be on the rest params sent to /me. - #expect(updateParams.value?._unsafeMetadata == nil) - #expect(metadataPatch.value == ["foo": "new", "bar": "added"]) - } - - @Test - @available(*, deprecated) - func identicalMetadataShortCircuitsWithoutMetadataCall() async throws { - let updateCalls = LockIsolated(0) - let metadataCalls = LockIsolated(0) - - let service = MockUserService( - update: { _ in - updateCalls.withValue { $0 += 1 } - return .mock - }, - updateMetadata: { _ in - metadataCalls.withValue { $0 += 1 } - return .mock - } - ) - configureService(service) - - var user = User.mock - user.unsafeMetadata = ["theme": "dark"] - - _ = try await user.update(.init(unsafeMetadata: ["theme": "dark"])) - - #expect(updateCalls.value == 0) - #expect(metadataCalls.value == 0) - } - - @Test - @available(*, deprecated) - func identicalMetadataWithOtherFieldsReturnsTheUpdateMeResponse() async throws { - // Receiver has stale firstName; the /me response (User.mock2) has the fresh one. - // The bug to guard against: empty-patch short-circuit returning stale `self` - // instead of the fresh `afterPatch`. - let metadataCalls = LockIsolated(0) - let service = MockUserService( - update: { _ in - var fresh = User.mock - fresh.firstName = "Fresh" - fresh.unsafeMetadata = ["theme": "dark"] - return fresh - }, - updateMetadata: { _ in - metadataCalls.withValue { $0 += 1 } - return .mock - } - ) - configureService(service) - - var user = User.mock - user.firstName = "Stale" - user.unsafeMetadata = ["theme": "dark"] - - let returned = try await user.update( - .init(firstName: "Fresh", unsafeMetadata: ["theme": "dark"]) - ) - - #expect(metadataCalls.value == 0, "Identical metadata must not trigger a /me/metadata call") - #expect(returned.firstName == "Fresh", "Must return the fresh /me response, not stale self") - } - - // MARK: - Helpers - - private func configureService(_ service: MockUserService) { - Clerk.shared.dependencies = MockDependencyContainer( - apiClient: createMockAPIClient(), - userService: service - ) - try! (Clerk.shared.dependencies as! MockDependencyContainer) - .configurationManager - .configure(publishableKey: testPublishableKey, options: .init()) - } -} diff --git a/Tests/Utils/JSONMergePatchTests.swift b/Tests/Utils/JSONMergePatchTests.swift deleted file mode 100644 index 52722c93c..000000000 --- a/Tests/Utils/JSONMergePatchTests.swift +++ /dev/null @@ -1,129 +0,0 @@ -@testable import ClerkKit -import Foundation -import Testing - -struct JSONMergePatchTests { - @Test - func addedKeysAppearVerbatim() { - let current: JSON = ["a": 1] - let desired: JSON = ["a": 1, "b": 2] - #expect(current.mergePatch(against: desired) == ["b": 2]) - } - - @Test - func keysAbsentFromDesiredBecomeNull() { - let current: JSON = ["a": 1, "b": 2] - let desired: JSON = ["a": 1] - #expect(current.mergePatch(against: desired) == ["b": .null]) - } - - @Test - func changedPrimitiveValuesAreOverwritten() { - let current: JSON = ["a": 1] - let desired: JSON = ["a": 2] - #expect(current.mergePatch(against: desired) == ["a": 2]) - } - - @Test - func unchangedValuesAreSkipped() { - let current: JSON = ["a": 1, "b": 2] - let desired: JSON = ["a": 1, "b": 2] - #expect(current.mergePatch(against: desired) == .object([:])) - } - - @Test - func nestedObjectsRecurseAndEmitOnlyChangedSubKeys() { - let current: JSON = ["profile": ["theme": "dark", "font": "sans"]] - let desired: JSON = ["profile": ["theme": "light", "font": "sans"]] - #expect(current.mergePatch(against: desired) == ["profile": ["theme": "light"]]) - } - - @Test - func removedNestedKeyIsNulledSiblingsUntouched() { - let current: JSON = ["profile": ["theme": "dark", "font": "sans"]] - let desired: JSON = ["profile": ["font": "sans"]] - #expect(current.mergePatch(against: desired) == ["profile": ["theme": .null]]) - } - - @Test - func typeMismatchReturnsDesiredVerbatim() { - let current: JSON = ["a": 1] - let desired: JSON = "replaced" - #expect(current.mergePatch(against: desired) == "replaced") - } - - @Test - func nullDesiredIsPassedThroughVerbatim() { - let current: JSON = ["a": 1] - #expect(current.mergePatch(against: .null) == .null) - } - - @Test - func desiredEmptyObjectClearsEveryExistingKey() { - let current: JSON = ["a": 1, "b": 2] - let desired: JSON = .object([:]) - #expect(current.mergePatch(against: desired) == ["a": .null, "b": .null]) - } - - @Test - func emptyCurrentReturnsDesiredVerbatim() { - let current: JSON = .object([:]) - let desired: JSON = ["a": 1, "b": ["c": 2]] - #expect(current.mergePatch(against: desired) == desired) - } - - @Test - func arraysAreTreatedAsAtomic() { - // RFC 7396 explicitly treats arrays as opaque. - let current: JSON = ["tags": ["a", "b"]] - let desired: JSON = ["tags": ["a"]] - #expect(current.mergePatch(against: desired) == ["tags": ["a"]]) - } - - @Test - func applyingThePatchReproducesDesired() { - // Use a local RFC 7396 applier (null deletes) as the oracle. JSON.merging(with:) - // is *not* RFC 7396 — it retains .null values instead of deleting keys. - let current: JSON = [ - "a": 1, - "nested": ["x": 1, "y": 2], - "removed": true, - ] - let desired: JSON = [ - "a": 2, - "nested": ["x": 1, "z": 3], - "added": "yes", - ] - - let patch = current.mergePatch(against: desired) - #expect(applyMergePatch(current, patch) == desired) - if case let .object(patchObj) = patch { - #expect(!patchObj.isEmpty, "Patch must not be empty for a real change") - } else { - Issue.record("Expected the patch to be a JSON object") - } - } - - // MARK: - Helpers - - /// RFC 7396 reference applier: null values delete keys, recursion on objects, - /// non-object patch values fully replace at that node. - private func applyMergePatch(_ target: JSON, _ patch: JSON) -> JSON { - guard case let .object(patchObj) = patch else { return patch } - var out: [String: JSON] = - if case let .object(t) = target { - t - } else { - [:] - } - for (key, value) in patchObj { - if case .null = value { - out.removeValue(forKey: key) - } else { - let nested = out[key] ?? .object([:]) - out[key] = applyMergePatch(nested, value) - } - } - return .object(out) - } -} diff --git a/Tests/Utils/JSONUtilitiesTests.swift b/Tests/Utils/JSONUtilitiesTests.swift index 662038b4e..28d4001ad 100644 --- a/Tests/Utils/JSONUtilitiesTests.swift +++ b/Tests/Utils/JSONUtilitiesTests.swift @@ -203,6 +203,110 @@ struct JSONUtilitiesTests { #expect(merged.stringValue == "new") // Returns new when not objects } + @Test + func mergePatchRemovesMissingKeys() { + let current: JSON = [ + "token": "old-value", + "stale": true, + "nested": [ + "keep": "same", + "remove": "old", + ], + ] + let replacement: JSON = [ + "token": "new-value", + "nested": [ + "keep": "same", + "added": "new", + ], + ] + + let patch = current.mergePatch(against: replacement) + + #expect(patch == [ + "token": "new-value", + "stale": .null, + "nested": [ + "added": "new", + "remove": .null, + ], + ]) + } + + @Test + func mergePatchReturnsEmptyObjectForIdenticalObjects() { + let current: JSON = [ + "token": "some-value", + "nested": ["keep": true], + ] + + #expect(current.mergePatch(against: current) == .object([:])) + } + + @Test + func mergePatchReturnsDesiredForTopLevelReplacement() { + let current: JSON = ["token": "some-value"] + + #expect(current.mergePatch(against: "replacement") == "replacement") + #expect(current.mergePatch(against: .null) == .null) + } + + @Test + func mergePatchTreatsArraysAsAtomicValues() { + let current: JSON = [ + "tags": ["one", "two"], + "nested": [ + "items": [1, 2], + ], + ] + let desired: JSON = [ + "tags": ["one"], + "nested": [ + "items": [2, 3], + ], + ] + + #expect(current.mergePatch(against: desired) == [ + "tags": ["one"], + "nested": [ + "items": [2, 3], + ], + ]) + } + + @Test + func mergePatchRemovesNestedKeysWhenDesiredNestedObjectIsEmpty() { + let current: JSON = [ + "prefs": [ + "theme": "dark", + "font": "sans", + ], + ] + let desired: JSON = [ + "prefs": .object([:]), + ] + + #expect(current.mergePatch(against: desired) == [ + "prefs": [ + "theme": .null, + "font": .null, + ], + ]) + } + + @Test + func mergePatchRemovesAllKeysWhenDesiredObjectIsEmpty() { + let current: JSON = [ + "token": "some-value", + "nested": ["keep": true], + ] + + #expect(current.mergePatch(against: .object([:])) == [ + "token": .null, + "nested": .null, + ]) + } + // MARK: - Codable @Test diff --git a/Tests/Utils/URLEncodedFormEncoderTests.swift b/Tests/Utils/URLEncodedFormEncoderTests.swift index 85a4e0db6..c2103e5f9 100644 --- a/Tests/Utils/URLEncodedFormEncoderTests.swift +++ b/Tests/Utils/URLEncodedFormEncoderTests.swift @@ -8,6 +8,26 @@ import Testing @Suite(.serialized) struct URLEncodedFormEncoderTests { + @Test + func clerkFormMiddlewarePreservesNestedNullsInStringifiedMetadata() async throws { + let url = try #require(URL(string: "https://example.com/v1/me/metadata")) + var request = URLRequest(url: url) + request.httpMethod = "PATCH" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder.clerkEncoder.encode( + User.UpdateMetadataParams(unsafeMetadata: ["kept": "yes", "removed": .null]) + ) + + try await ClerkURLEncodedFormEncoderMiddleware().prepare(&request) + + let unsafeMetadata = try #require(request.urlEncodedFormBody?["unsafe_metadata"]) + #expect(unsafeMetadata.contains("\"kept\":\"yes\"")) + #expect(unsafeMetadata.contains("\"removed\":null")) + + let unsafeMetadataJSON = try JSONDecoder.clerkDecoder.decode(JSON.self, from: Data(unsafeMetadata.utf8)) + #expect(unsafeMetadataJSON == ["kept": "yes", "removed": .null]) + } + // MARK: - ArrayEncoding Tests @Test