diff --git a/Sources/ClerkKit/Domains/User/User.swift b/Sources/ClerkKit/Domains/User/User.swift index 194737146..6124e06c5 100644 --- a/Sources/ClerkKit/Domains/User/User.swift +++ b/Sources/ClerkKit/Domains/User/User.swift @@ -222,9 +222,52 @@ 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. + /// + /// - 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 { - try await userService.update(params: params) + let service = userService + + guard let desiredUnsafeMetadata = params.deprecatedUnsafeMetadata else { + return try await service.update(params: params) + } + + let paramsWithoutUnsafeMetadata = params.withoutUnsafeMetadata + let hasProfileUpdates = paramsWithoutUnsafeMetadata.hasAnyField + let userAfterProfileUpdate: User = + if hasProfileUpdates { + try await service.update(params: paramsWithoutUnsafeMetadata) + } else { + try await service.reload() + } + + 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: .init(unsafeMetadata: patch)) + } + + /// Updates the user's unsafe metadata. + /// + /// 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) + } + + /// Updates the user's unsafe metadata. + /// + /// 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)) } /// 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/UserUpdateParams.swift b/Sources/ClerkKit/Domains/User/UserUpdateParams.swift index 7ff6c9990..3b02f881f 100644 --- a/Sources/ClerkKit/Domains/User/UserUpdateParams.swift +++ b/Sources/ClerkKit/Domains/User/UserUpdateParams.swift @@ -3,22 +3,42 @@ // Clerk // +import Foundation + 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 metadata updates. Passing unsafeMetadata to update(_:) 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 + deprecatedUnsafeMetadata = unsafeMetadata } /// The user's username. @@ -36,10 +56,56 @@ 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? + 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 metadata updates. Passing unsafeMetadata to update(_:) will be removed in a future major version." + ) + public var unsafeMetadata: JSON? { + get { deprecatedUnsafeMetadata } + set { deprecatedUnsafeMetadata = newValue } + } + + private enum CodingKeys: String, CodingKey { + case username + case firstName + case lastName + case primaryEmailAddressId + case primaryPhoneNumberId + 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 { + var hasAnyField: Bool { + var copy = self + 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 !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 702364b83..1912df0d0 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)? @@ -91,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. @@ -132,6 +136,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 +157,7 @@ package final class MockUserService: UserServiceProtocol { getSessionsHandler = getSessions reloadHandler = reload updateHandler = update + updateMetadataHandler = updateMetadata createBackupCodesHandler = createBackupCodes createEmailAddressHandler = createEmailAddress createPhoneNumberHandler = createPhoneNumber @@ -195,6 +201,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..13758d0fc --- /dev/null +++ b/Sources/ClerkKit/Utils/JSON+MergePatch.swift @@ -0,0 +1,50 @@ +// +// JSON+MergePatch.swift +// Clerk +// + +extension JSON { + func mergePatch(against desired: JSON) -> JSON { + 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, desiredValue) in desired { + guard let currentValue = current[key] else { + patch[key] = desiredValue + continue + } + + 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 current.keys where desired[key] == nil { + patch[key] = .null + } + + return 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/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/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