From eb25ee1784bce0c4c5d63d19c3a02ce8459a4708 Mon Sep 17 00:00:00 2001 From: Diego Diestra Date: Thu, 12 Feb 2026 08:53:18 -0500 Subject: [PATCH 1/3] feat: implement token update --- .../BasisTheoryElements.swift | 45 +++++++++ .../BasisTheoryElements/UpdateToken.swift | 46 +++++++++ .../UpdateTokenRequest.swift | 14 +++ .../project.pbxproj | 4 + .../TextElementUITextFieldTests.swift | 85 ++++++++++++++++ .../UnitTests/UpdateTokenTests.swift | 98 +++++++++++++++++++ 6 files changed, 292 insertions(+) create mode 100644 BasisTheoryElements/Sources/BasisTheoryElements/UpdateToken.swift create mode 100644 BasisTheoryElements/Sources/BasisTheoryElements/UpdateTokenRequest.swift create mode 100644 IntegrationTester/UnitTests/UpdateTokenTests.swift diff --git a/BasisTheoryElements/Sources/BasisTheoryElements/BasisTheoryElements.swift b/BasisTheoryElements/Sources/BasisTheoryElements/BasisTheoryElements.swift index 8fff90e..4a8be99 100644 --- a/BasisTheoryElements/Sources/BasisTheoryElements/BasisTheoryElements.swift +++ b/BasisTheoryElements/Sources/BasisTheoryElements/BasisTheoryElements.swift @@ -282,6 +282,51 @@ final public class BasisTheoryElements { } } + public static func updateToken(id: String, body: UpdateToken, apiKey: String? = nil, completion: @escaping ((_ data: CreateTokenResponse?, _ error: Error?) -> Void)) -> Void { + let endpoint = "PATCH /tokens/\(id)" + let btTraceId = UUID().uuidString + logBeginningOfApiCall(endpoint: endpoint, btTraceId: btTraceId) + + var mutableBody = body + var mutableData = body.data + do { + try replaceElementRefs(body: &mutableData, endpoint: endpoint, btTraceId: btTraceId) + } catch { + completion(nil, TokenizingError.invalidInput) + return + } + + mutableBody.data = mutableData + let updateTokenRequest = mutableBody.toUpdateTokenRequest() + + var headers = getBasisTheoryHeaders(apiKey: getApiKey(apiKey), btTraceId: btTraceId) + headers["Content-Type"] = "application/merge-patch+json" + let url = "\(basePath)/tokens/\(id)" + + HttpClientHelpers.executeTypedRequest( + method: .patch, + url: url, + headers: headers, + body: updateTokenRequest + ) { (result: CreateTokenResponse?, error: Error?) in + if let error = error { + TelemetryLogging.error("Unsuccessful API response", error: error, attributes: [ + "endpoint": endpoint, + "BT-TRACE-ID": btTraceId, + "apiSuccess": false + ]) + completion(nil, error) + } else { + TelemetryLogging.info("Successful API response", attributes: [ + "endpoint": endpoint, + "BT-TRACE-ID": btTraceId, + "apiSuccess": true + ]) + completion(result, nil) + } + } + } + public static func createTokenIntent( request: CreateTokenIntentRequest, apiKey: String? = nil, completion: @escaping ((_ data: TokenIntent?, _ error: Error?) -> Void) diff --git a/BasisTheoryElements/Sources/BasisTheoryElements/UpdateToken.swift b/BasisTheoryElements/Sources/BasisTheoryElements/UpdateToken.swift new file mode 100644 index 0000000..be786ba --- /dev/null +++ b/BasisTheoryElements/Sources/BasisTheoryElements/UpdateToken.swift @@ -0,0 +1,46 @@ +// +// UpdateToken.swift +// +// +// Created by Cascade on 02/11/26. +// + +import AnyCodable + +public struct UpdateToken { + public var data: [String: Any] + public var privacy: Privacy? + public var metadata: [String: String]? + public var searchIndexes: [String]? + public var fingerprintExpression: String? + public var mask: String? + public var deduplicateToken: Bool? + public var expiresAt: String? + public var containers: [String]? + + public init(data: [String: Any], privacy: Privacy? = nil, metadata: [String: String]? = nil, searchIndexes: [String]? = nil, fingerprintExpression: String? = nil, mask: String? = nil, deduplicateToken: Bool? = nil, expiresAt: String? = nil, containers: [String]? = nil) { + self.data = data + self.privacy = privacy + self.metadata = metadata + self.searchIndexes = searchIndexes + self.fingerprintExpression = fingerprintExpression + self.mask = mask + self.deduplicateToken = deduplicateToken + self.expiresAt = expiresAt + self.containers = containers + } + + func toUpdateTokenRequest() -> UpdateTokenRequest { + UpdateTokenRequest( + data: AnyCodable(self.data), + privacy: self.privacy, + metadata: self.metadata, + searchIndexes: self.searchIndexes, + fingerprintExpression: self.fingerprintExpression, + mask: AnyCodable(self.mask), + deduplicateToken: self.deduplicateToken, + expiresAt: self.expiresAt, + containers: self.containers + ) + } +} diff --git a/BasisTheoryElements/Sources/BasisTheoryElements/UpdateTokenRequest.swift b/BasisTheoryElements/Sources/BasisTheoryElements/UpdateTokenRequest.swift new file mode 100644 index 0000000..5ca58d4 --- /dev/null +++ b/BasisTheoryElements/Sources/BasisTheoryElements/UpdateTokenRequest.swift @@ -0,0 +1,14 @@ +import Foundation +import AnyCodable + +internal struct UpdateTokenRequest: Codable { + var data: AnyCodable? + var privacy: Privacy? + var metadata: [String: String]? + var searchIndexes: [String]? + var fingerprintExpression: String? + var mask: AnyCodable? + var deduplicateToken: Bool? + var expiresAt: String? + var containers: [String]? +} diff --git a/IntegrationTester/IntegrationTester.xcodeproj/project.pbxproj b/IntegrationTester/IntegrationTester.xcodeproj/project.pbxproj index e6448ef..c8546d4 100644 --- a/IntegrationTester/IntegrationTester.xcodeproj/project.pbxproj +++ b/IntegrationTester/IntegrationTester.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 0A5844202E32C81800D61EAF /* TokenIntentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A58441D2E32C81800D61EAF /* TokenIntentViewController.swift */; }; 0AB437902A4C9FB4000099CD /* HttpClientViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AB4378F2A4C9FB4000099CD /* HttpClientViewController.swift */; }; 0AD41CA82E32C8D4002070F8 /* TokenIntentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AD41CA72E32C8D4002070F8 /* TokenIntentTests.swift */; }; + CA5CAD022F0C000000000001 /* UpdateTokenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5CAD012F0C000000000001 /* UpdateTokenTests.swift */; }; 428CB1062937C53000C8220E /* CardExpirationDateUITextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428CB1052937C53000C8220E /* CardExpirationDateUITextFieldTests.swift */; }; 428CB10A293E398B00C8220E /* CardNumberUITextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 428CB109293E398B00C8220E /* CardNumberUITextFieldTests.swift */; }; 5EC845072EEB95F100B10269 /* EnvironmentConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC844F82EEA49ED00B10269 /* EnvironmentConfigurationTests.swift */; }; @@ -87,6 +88,7 @@ 0A58441D2E32C81800D61EAF /* TokenIntentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenIntentViewController.swift; sourceTree = ""; }; 0AB4378F2A4C9FB4000099CD /* HttpClientViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpClientViewController.swift; sourceTree = ""; }; 0AD41CA72E32C8D4002070F8 /* TokenIntentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenIntentTests.swift; sourceTree = ""; }; + CA5CAD012F0C000000000001 /* UpdateTokenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateTokenTests.swift; sourceTree = ""; }; 428CB1052937C53000C8220E /* CardExpirationDateUITextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardExpirationDateUITextFieldTests.swift; sourceTree = ""; }; 428CB109293E398B00C8220E /* CardNumberUITextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardNumberUITextFieldTests.swift; sourceTree = ""; }; 5EC844F82EEA49ED00B10269 /* EnvironmentConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentConfigurationTests.swift; sourceTree = ""; }; @@ -220,6 +222,7 @@ 023FB3372EC361D800CD88DF /* CardBrandSelectorUIButtonTests.swift */, 023FB3382EC361D800CD88DF /* CobadgeIntegrationTests.swift */, 0AD41CA72E32C8D4002070F8 /* TokenIntentTests.swift */, + CA5CAD012F0C000000000001 /* UpdateTokenTests.swift */, 646506012DC9490300660095 /* EncryptTokenTests.swift */, 929E30C22948D0F900A01A56 /* TokenizeAndCreateTokenServiceTests.swift */, 9253E8E1291EB66E00FDF1A9 /* ElementUITextFieldTests.swift */, @@ -473,6 +476,7 @@ 929E30C32948D0F900A01A56 /* TokenizeAndCreateTokenServiceTests.swift in Sources */, 92B7DCAF2939523C000DBB8A /* CardBrandTests.swift in Sources */, 0AD41CA82E32C8D4002070F8 /* TokenIntentTests.swift in Sources */, + CA5CAD022F0C000000000001 /* UpdateTokenTests.swift in Sources */, 023FB3392EC361D800CD88DF /* CardBrandSelectorUIButtonTests.swift in Sources */, 023FB33A2EC361D800CD88DF /* CobadgeIntegrationTests.swift in Sources */, 428CB1062937C53000C8220E /* CardExpirationDateUITextFieldTests.swift in Sources */, diff --git a/IntegrationTester/UnitTests/TextElementUITextFieldTests.swift b/IntegrationTester/UnitTests/TextElementUITextFieldTests.swift index 90eb210..49c0ab3 100644 --- a/IntegrationTester/UnitTests/TextElementUITextFieldTests.swift +++ b/IntegrationTester/UnitTests/TextElementUITextFieldTests.swift @@ -414,4 +414,89 @@ final class TextElementUITextFieldTests: XCTestCase { waitForExpectations(timeout: 3) } + + func testTextSetterTriggersChangeEventWithValidation() throws { + let textField = TextElementUITextField() + let customRegex = try! NSRegularExpression(pattern: "^\\d{16}$") + try! textField.setConfig(options: TextElementOptions(validation: customRegex)) + + let changeExpectation = self.expectation(description: "text setter triggers change event") + var cancellables = Set() + + textField.subject.sink { completion in + print(completion) + } receiveValue: { message in + XCTAssertEqual(message.type, "textChange") + XCTAssertEqual(message.complete, true) + XCTAssertEqual(message.valid, true) + XCTAssertEqual(message.empty, false) + + // assert metadata updated + XCTAssertEqual(textField.metadata.complete, message.complete) + XCTAssertEqual(textField.metadata.valid, message.valid) + XCTAssertEqual(textField.metadata.empty, message.empty) + + changeExpectation.fulfill() + }.store(in: &cancellables) + + textField.text = "4242424242424242" + + waitForExpectations(timeout: 1, handler: nil) + } + + func testTextSetterTriggersChangeEventWithInvalidValue() throws { + let textField = TextElementUITextField() + let customRegex = try! NSRegularExpression(pattern: "^\\d{16}$") + try! textField.setConfig(options: TextElementOptions(validation: customRegex)) + + let changeExpectation = self.expectation(description: "text setter triggers change event with invalid value") + var cancellables = Set() + + textField.subject.sink { completion in + print(completion) + } receiveValue: { message in + XCTAssertEqual(message.type, "textChange") + XCTAssertEqual(message.complete, false) + XCTAssertEqual(message.valid, false) + XCTAssertEqual(message.empty, false) + + // assert metadata updated + XCTAssertEqual(textField.metadata.complete, message.complete) + XCTAssertEqual(textField.metadata.valid, message.valid) + XCTAssertEqual(textField.metadata.empty, message.empty) + + changeExpectation.fulfill() + }.store(in: &cancellables) + + textField.text = "4242" + + waitForExpectations(timeout: 1, handler: nil) + } + + func testTextSetterTriggersChangeEventWithEmptyValue() throws { + let textField = TextElementUITextField() + + let changeExpectation = self.expectation(description: "text setter triggers change event with empty value") + var cancellables = Set() + + textField.subject.sink { completion in + print(completion) + } receiveValue: { message in + XCTAssertEqual(message.type, "textChange") + XCTAssertEqual(message.complete, true) + XCTAssertEqual(message.valid, true) + XCTAssertEqual(message.empty, true) + + // assert metadata updated + XCTAssertEqual(textField.metadata.complete, message.complete) + XCTAssertEqual(textField.metadata.valid, message.valid) + XCTAssertEqual(textField.metadata.empty, message.empty) + + changeExpectation.fulfill() + }.store(in: &cancellables) + + textField.text = "" + + waitForExpectations(timeout: 1, handler: nil) + } } diff --git a/IntegrationTester/UnitTests/UpdateTokenTests.swift b/IntegrationTester/UnitTests/UpdateTokenTests.swift new file mode 100644 index 0000000..b5ea06d --- /dev/null +++ b/IntegrationTester/UnitTests/UpdateTokenTests.swift @@ -0,0 +1,98 @@ +// +// UpdateTokenTests.swift +// IntegrationTesterTests +// + +import XCTest +import BasisTheoryElements + +final class UpdateTokenTests: XCTestCase { + private final var TIMEOUT_EXPECTATION = 5.0 + + override func setUpWithError() throws { + BasisTheoryElements.basePath = "https://api.flock-dev.com" + } + + override func tearDownWithError() throws {} + + func testUpdateTokenReturnsErrorFromApplicationCheck() throws { + let body = UpdateToken(data: [ + "cvc": "123" + ]) + + let updateExpectation = self.expectation(description: "Update token") + BasisTheoryElements.updateToken(id: "invalid-token-id", body: body, apiKey: "bad api key") { data, error in + XCTAssertNil(data) + XCTAssertNotNil(error) + + updateExpectation.fulfill() + } + + waitForExpectations(timeout: TIMEOUT_EXPECTATION) + } + + func testUpdateTokenWithElementRef() throws { + let btApiKey = Configuration.getConfiguration().btApiKey! + + // First create a token to update + let createExpectation = self.expectation(description: "Create token") + var tokenId: String? + + let createBody = CreateToken(type: "card", data: [ + "number": "4242424242424242", + "expiration_month": 12, + "expiration_year": 2026, + "cvc": "123" + ]) + + BasisTheoryElements.createToken(body: createBody, apiKey: btApiKey) { data, error in + XCTAssertNil(error) + XCTAssertNotNil(data?.id) + tokenId = data?.id + createExpectation.fulfill() + } + + waitForExpectations(timeout: TIMEOUT_EXPECTATION) + + guard let id = tokenId else { + XCTFail("Failed to create token for update test") + return + } + + // Now update the token with new CVC + let updateExpectation = self.expectation(description: "Update token") + + let updateBody = UpdateToken(data: [ + "cvc": "456" + ]) + + BasisTheoryElements.updateToken(id: id, body: updateBody, apiKey: btApiKey) { data, error in + XCTAssertNil(error) + XCTAssertNotNil(data) + XCTAssertEqual(data?.id, id) + + updateExpectation.fulfill() + } + + waitForExpectations(timeout: TIMEOUT_EXPECTATION) + } + + func testUpdateTokenWithInvalidId() throws { + let btApiKey = Configuration.getConfiguration().btApiKey! + + let updateExpectation = self.expectation(description: "Update token with invalid id") + + let updateBody = UpdateToken(data: [ + "cvc": "456" + ]) + + BasisTheoryElements.updateToken(id: "nonexistent-token-id", body: updateBody, apiKey: btApiKey) { data, error in + XCTAssertNil(data) + XCTAssertNotNil(error) + + updateExpectation.fulfill() + } + + waitForExpectations(timeout: TIMEOUT_EXPECTATION) + } +} From 59b4a9a4cfcf2e4cc156f9166b7a0da29078151c Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 13 Feb 2026 05:13:51 -0600 Subject: [PATCH 2/3] fix: specify arm64 architecture and use iPhone 17 Pro in CI - Explicitly set arch=arm64 in xcodebuild destination to resolve ambiguity warning where the same device ID matches both arm64 and x86_64 architectures - Change from iPhone 16 Pro to iPhone 17 Pro to ensure iOS 26.2 simulator availability on all CI runners This ensures consistent architecture selection and device availability, which should fix intermittent CI test failures. Fixes the warning: "Using the first of multiple matching destinations" Co-Authored-By: Claude Sonnet 4.5 --- .github/scripts/integration-test.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/scripts/integration-test.sh b/.github/scripts/integration-test.sh index 898f1e0..dc131ef 100755 --- a/.github/scripts/integration-test.sh +++ b/.github/scripts/integration-test.sh @@ -23,11 +23,11 @@ cat < ./IntegrationTester/Env.plist EOT -# Find iPhone 16 Pro device ID from the latest iOS version -DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone 16 Pro" | tail -1 | grep -oE '[A-F0-9-]{36}') +# Find iPhone 17 Pro device ID from the latest iOS version +DEVICE_ID=$(xcrun simctl list devices available | grep "iPhone 17 Pro" | tail -1 | grep -oE '[A-F0-9-]{36}') if [ -z "$DEVICE_ID" ]; then - echo "Error: No iPhone 16 Pro simulator found" + echo "Error: No iPhone 17 Pro simulator found" xcrun simctl list devices available exit 1 fi @@ -40,5 +40,5 @@ xcodebuild clean test \ -project ./IntegrationTester/IntegrationTester.xcodeproj \ -scheme IntegrationTester \ -configuration Debug \ - -destination "platform=iOS Simulator,id=$DEVICE_ID" \ + -destination "platform=iOS Simulator,arch=arm64,id=$DEVICE_ID" \ | xcpretty From 9d89a39a89a5ec12cab991b74f5655e003dedcc7 Mon Sep 17 00:00:00 2001 From: kevin Date: Fri, 13 Feb 2026 05:58:33 -0600 Subject: [PATCH 3/3] perf: use xlarge macOS runner for integration tests Switch from macos-latest (3 vCPU) to macos-latest-xlarge (12 vCPU, 30GB RAM) to resolve test timeout issues. Tests were completing in ~2.4s locally but exceeding the 3s timeout on standard CI runners. This ensures tests have sufficient performance headroom to complete reliably. Co-Authored-By: Claude Sonnet 4.5 --- .github/workflows/pull-request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d232f7c..afb187d 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -9,7 +9,7 @@ jobs: test: environment: PR name: Testing BasisTheoryElements Package - runs-on: macOS-latest + runs-on: macos-latest-xlarge steps: - name: Checkout repository uses: actions/checkout@v3