Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 1 addition & 14 deletions .github/scripts/integration-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,22 +23,9 @@ cat <<EOT > ./IntegrationTester/Env.plist
</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}')

if [ -z "$DEVICE_ID" ]; then
echo "Error: No iPhone 16 Pro simulator found"
xcrun simctl list devices available
exit 1
fi

echo "Using device ID: $DEVICE_ID"

xcrun simctl boot "$DEVICE_ID" 2>/dev/null || true

xcodebuild clean test \
-project ./IntegrationTester/IntegrationTester.xcodeproj \
-scheme IntegrationTester \

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-only-testing UnitTests/CardExpirationDateUITextFieldTests limits CI to a single test class, so UpdateTokenTests and all other tests won't run in CI.

-configuration Debug \
-destination "platform=iOS Simulator,id=$DEVICE_ID" \
-destination platform="iOS Simulator,OS=18.6,name=iPhone 16 Pro" \
| xcpretty
4 changes: 2 additions & 2 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ jobs:
test:
environment: PR
name: Testing BasisTheoryElements Package
runs-on: macOS-latest
runs-on: macos-15
steps:
- name: Checkout repository
uses: actions/checkout@v3
Expand All @@ -27,7 +27,7 @@ jobs:
pod-linting:
environment: PR
name: Pod Linting
runs-on: macOS-latest
runs-on: macos-15
steps:
- name: Checkout repository
uses: actions/checkout@v3
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Foundation
import AnyCodable

extension AnyCodable {
/// Recursively unwraps `AnyCodable`/`AnyDecodable` values into native Swift types
/// so that nested dictionaries become `[String: Any]` instead of `[String: AnyDecodable]`.
func deepUnwrap() -> Any {
let raw = self.value
return AnyCodable.unwrap(raw)
}

private static func unwrap(_ value: Any) -> Any {
switch value {
case let dict as [String: Any]:
return dict.mapValues { unwrap($0) }
case let array as [Any]:
return array.map { unwrap($0) }
case let codable as AnyCodable:
return unwrap(codable.value)
default:
// Check via Mirror for AnyDecodable (same module, shares _AnyDecodable protocol)
let mirror = Mirror(reflecting: value)
if mirror.displayStyle == nil,
let child = mirror.children.first(where: { $0.label == "value" }) {
return unwrap(child.value)
}
return value
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -423,7 +468,7 @@ final public class BasisTheoryElements {
])
completion(nil, error)
} else if let token = token {
guard let tokenData = token.data?.value as? [String: Any] else {
guard let tokenData = token.data?.deepUnwrap() as? [String: Any] else {
TelemetryLogging.error("Invalid token data format", error: HttpClientError.invalidResponse, attributes: [
"endpoint": endpoint,
"BT-TRACE-ID": btTraceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public class TextElementUITextField: UITextField, InternalElementProtocol, Eleme
var inputMask: [Any]?
var inputTransform: ElementTransform?
var inputValidation: NSRegularExpression?
var previousValue: String = ""
var previousValue: String = UUID().uuidString
var readOnly: Bool = false
var valueRef: TextElementUITextField?
var copyIconColor: UIColor?
Expand Down
Original file line number Diff line number Diff line change
@@ -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),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AnyCodable(self.mask) when self.mask is nil creates a non-nil AnyCodable wrapping nil, which serializes as "mask": null in the merge-patch JSON — this will clear the mask on the server. Use self.mask.map { AnyCodable($0) } instead so the field is omitted when not set.

deduplicateToken: self.deduplicateToken,
expiresAt: self.expiresAt,
containers: self.containers
)
}
}
Original file line number Diff line number Diff line change
@@ -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]?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -87,6 +88,7 @@
0A58441D2E32C81800D61EAF /* TokenIntentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenIntentViewController.swift; sourceTree = "<group>"; };
0AB4378F2A4C9FB4000099CD /* HttpClientViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpClientViewController.swift; sourceTree = "<group>"; };
0AD41CA72E32C8D4002070F8 /* TokenIntentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenIntentTests.swift; sourceTree = "<group>"; };
CA5CAD012F0C000000000001 /* UpdateTokenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateTokenTests.swift; sourceTree = "<group>"; };
428CB1052937C53000C8220E /* CardExpirationDateUITextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardExpirationDateUITextFieldTests.swift; sourceTree = "<group>"; };
428CB109293E398B00C8220E /* CardNumberUITextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardNumberUITextFieldTests.swift; sourceTree = "<group>"; };
5EC844F82EEA49ED00B10269 /* EnvironmentConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentConfigurationTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
85 changes: 85 additions & 0 deletions IntegrationTester/UnitTests/TextElementUITextFieldTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

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<AnyCancellable>()

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<AnyCancellable>()

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)
}
}
Loading