From d383d0aab5e6837f40667c9e36769b3f0c23ea05 Mon Sep 17 00:00:00 2001 From: dtroupe Date: Tue, 9 Jun 2026 08:05:21 -0700 Subject: [PATCH 1/2] Move ObjC support into SPM package --- .github/workflows/ci.yml | 21 +- .../LinkDemo-ObjC.xcodeproj/project.pbxproj | 371 +++++++++ LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.h | 14 + LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.m | 40 + .../LinkDemo-ObjC/App/SceneDelegate.h | 15 + .../LinkDemo-ObjC/App/SceneDelegate.m | 32 + LinkDemo-ObjC/LinkDemo-ObjC/App/main.m | 18 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 35 + .../Assets.xcassets/Contents.json | 6 + .../Base.lproj/LaunchScreen.storyboard | 25 + .../LinkDemo-ObjC/Base.lproj/Main.storyboard | 24 + .../LinkDemo-ObjC/ExampleListViewController.h | 16 + .../LinkDemo-ObjC/ExampleListViewController.m | 114 +++ LinkDemo-ObjC/LinkDemo-ObjC/Info.plist | 25 + .../LinkDemo-ObjC/LinkDemo-ObjC.entitlements | 10 + .../LinkDemo-ObjC/Models/ExampleView.h | 31 + .../LinkDemo-ObjC/Models/ExampleView.m | 64 ++ .../PlaidEmbeddedLinkViewController.h | 18 + .../PlaidEmbeddedLinkViewController.m | 194 +++++ .../FinanceKit/SyncFinanceKitViewController.h | 18 + .../FinanceKit/SyncFinanceKitViewController.m | 167 ++++ .../PlaidLinkHeadlessSessionViewController.h | 18 + .../PlaidLinkHeadlessSessionViewController.m | 202 +++++ .../PlaidLayerSessionViewController.h | 18 + .../PlaidLayerSessionViewController.m | 274 +++++++ .../PlaidLinkSessionViewController.h | 18 + .../PlaidLinkSessionViewController.m | 209 +++++ Package.swift | 22 +- README.md | 7 +- Sources/{ => LinkKitSub}/LinkKitStub.swift | 0 .../Resources/PrivacyInfo.xcprivacy | 0 Sources/ObjC-Support/ObjectiveC/PLKPlaid.m | 738 ++++++++++++++++++ .../ObjectiveC/include/LinkKitObjectiveC.h | 3 + .../include/PLKPlaid+PrivateInitializers.h | 122 +++ .../ObjectiveC/include/PLKPlaid.h | 673 ++++++++++++++++ .../Swift/Models/PLKAccount.swift | 15 + .../Swift/Models/PLKAccountSubtype.swift | 241 ++++++ .../Swift/Models/PLKApiErrorCode.swift | 18 + .../Models/PLKAssetReportErrorCode.swift | 28 + .../Swift/Models/PLKAuthErrorCode.swift | 18 + .../PLKEmbeddedLinkTokenConfiguration.swift | 27 + .../Swift/Models/PLKEnvironment.swift | 16 + .../Swift/Models/PLKEventMetadata.swift | 38 + .../Swift/Models/PLKEventName.swift | 154 ++++ .../Swift/Models/PLKExitError.swift | 75 ++ .../Swift/Models/PLKExitMetadata.swift | 15 + .../Swift/Models/PLKExitStatus.swift | 47 ++ .../Swift/Models/PLKInstitution.swift | 9 + .../Models/PLKInstitutionErrorCode.swift | 22 + .../Models/PLKInvalidInputErrorCode.swift | 30 + .../Models/PLKInvalidRequestErrorCode.swift | 28 + .../Swift/Models/PLKItemErrorCode.swift | 52 ++ .../Models/PLKLayerTokenConfiguration.swift | 22 + .../Swift/Models/PLKLinkEvent.swift | 9 + .../Swift/Models/PLKLinkExit.swift | 9 + .../Swift/Models/PLKLinkSuccess.swift | 9 + .../Models/PLKLinkTokenConfiguration.swift | 33 + .../Swift/Models/PLKMFAType.swift | 20 + .../Models/PLKPlaidConfigurationError.swift | 35 + .../Swift/Models/PLKPlaidFinanceKit.swift | 110 +++ .../Models/PLKPlaidHeadlessSession.swift | 16 + .../Swift/Models/PLKPlaidLayerSession.swift | 44 ++ .../Swift/Models/PLKPlaidLinkSession.swift | 34 + .../Swift/Models/PLKRateLimitErrorCode.swift | 30 + .../Swift/Models/PLKSuccessMetadata.swift | 14 + .../Swift/Models/PLKVerificationStatus.swift | 28 + .../Swift/Models/PLKViewName.swift | 114 +++ Sources/ObjC-Support/Swift/PLKPlaid.swift | 46 ++ .../Mocks/MockLayerSession.swift | 26 + .../Mocks/MockPlaidLinkSession.swift | 18 + .../PLKAccountSubtypeTests.swift | 279 +++++++ Tests/LinkKitObjCTests/PLKAccountTests.swift | 37 + .../PLKApiErrorCodeTests.swift | 25 + .../PLKAssetReportErrorCodeTests.swift | 57 ++ .../PLKAuthErrorCodeTests.swift | 25 + ...KEmbeddedLinkTokenConfigurationTests.swift | 166 ++++ .../PLKEnvironmentTests.swift | 20 + .../PLKEventMetadataTests.swift | 283 +++++++ .../LinkKitObjCTests/PLKEventNameTests.swift | 100 +++ .../LinkKitObjCTests/PLKExitErrorTests.swift | 343 ++++++++ .../PLKExitMetadataTests.swift | 42 + .../LinkKitObjCTests/PLKExitStatusTests.swift | 116 +++ .../PLKInstitutionErrorCodeTests.swift | 37 + .../PLKInstitutionTests.swift | 25 + .../PLKInvalidInputErrorCodeTests.swift | 79 ++ .../PLKInvalidRequestErrorCodeTests.swift | 55 ++ .../PLKItemErrorCodeTests.swift | 156 ++++ .../PLKLayerTokenConfigurationTests.swift | 166 ++++ .../LinkKitObjCTests/PLKLinkEventTests.swift | 134 ++++ Tests/LinkKitObjCTests/PLKLinkExitTests.swift | 301 +++++++ .../PLKLinkSuccessTests.swift | 213 +++++ .../PLKLinkTokenConfigurationTests.swift | 275 +++++++ Tests/LinkKitObjCTests/PLKMFATypeTests.swift | 34 + .../PLKPlaidConfigurationErrorTests.swift | 74 ++ .../PLKPlaidFinanceKitTests.swift | 241 ++++++ .../PLKPlaidHeadlessSessionTests.swift | 77 ++ .../PLKPlaidLayerSessionTests.swift | 235 ++++++ .../PLKPlaidLinkSessionTests.swift | 193 +++++ .../PLKRateLimitErrorCodeTests.swift | 98 +++ .../PLKSuccessMetadataTests.swift | 342 ++++++++ .../PLKVerificationStatusTests.swift | 184 +++++ Tests/LinkKitObjCTests/PLKViewNameTests.swift | 321 ++++++++ 103 files changed, 9751 insertions(+), 5 deletions(-) create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC.xcodeproj/project.pbxproj create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.h create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.m create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/App/SceneDelegate.h create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/App/SceneDelegate.m create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/App/main.m create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/Contents.json create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/Base.lproj/LaunchScreen.storyboard create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/Base.lproj/Main.storyboard create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ExampleListViewController.h create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ExampleListViewController.m create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/Info.plist create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/LinkDemo-ObjC.entitlements create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/Models/ExampleView.h create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/Models/ExampleView.m create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/EmbeddedSearch/PlaidEmbeddedLinkViewController.h create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/EmbeddedSearch/PlaidEmbeddedLinkViewController.m create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/FinanceKit/SyncFinanceKitViewController.h create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/FinanceKit/SyncFinanceKitViewController.m create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/HeadlessSession/PlaidLinkHeadlessSessionViewController.h create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/HeadlessSession/PlaidLinkHeadlessSessionViewController.m create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LayerSession/PlaidLayerSessionViewController.h create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LayerSession/PlaidLayerSessionViewController.m create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LinkSession/PlaidLinkSessionViewController.h create mode 100644 LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LinkSession/PlaidLinkSessionViewController.m rename Sources/{ => LinkKitSub}/LinkKitStub.swift (100%) rename Sources/{ => LinkKitSub}/Resources/PrivacyInfo.xcprivacy (100%) create mode 100644 Sources/ObjC-Support/ObjectiveC/PLKPlaid.m create mode 100644 Sources/ObjC-Support/ObjectiveC/include/LinkKitObjectiveC.h create mode 100644 Sources/ObjC-Support/ObjectiveC/include/PLKPlaid+PrivateInitializers.h create mode 100644 Sources/ObjC-Support/ObjectiveC/include/PLKPlaid.h create mode 100644 Sources/ObjC-Support/Swift/Models/PLKAccount.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKAccountSubtype.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKApiErrorCode.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKAssetReportErrorCode.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKAuthErrorCode.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKEmbeddedLinkTokenConfiguration.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKEnvironment.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKEventMetadata.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKEventName.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKExitError.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKExitMetadata.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKExitStatus.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKInstitution.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKInstitutionErrorCode.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKInvalidInputErrorCode.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKInvalidRequestErrorCode.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKItemErrorCode.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKLayerTokenConfiguration.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKLinkEvent.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKLinkExit.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKLinkSuccess.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKLinkTokenConfiguration.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKMFAType.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKPlaidConfigurationError.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKPlaidFinanceKit.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKPlaidHeadlessSession.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKPlaidLayerSession.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKPlaidLinkSession.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKRateLimitErrorCode.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKSuccessMetadata.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKVerificationStatus.swift create mode 100644 Sources/ObjC-Support/Swift/Models/PLKViewName.swift create mode 100644 Sources/ObjC-Support/Swift/PLKPlaid.swift create mode 100644 Tests/LinkKitObjCTests/Mocks/MockLayerSession.swift create mode 100644 Tests/LinkKitObjCTests/Mocks/MockPlaidLinkSession.swift create mode 100644 Tests/LinkKitObjCTests/PLKAccountSubtypeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKAccountTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKApiErrorCodeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKAssetReportErrorCodeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKAuthErrorCodeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKEmbeddedLinkTokenConfigurationTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKEnvironmentTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKEventMetadataTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKEventNameTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKExitErrorTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKExitMetadataTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKExitStatusTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKInstitutionErrorCodeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKInstitutionTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKInvalidInputErrorCodeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKInvalidRequestErrorCodeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKItemErrorCodeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKLayerTokenConfigurationTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKLinkEventTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKLinkExitTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKLinkSuccessTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKLinkTokenConfigurationTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKMFATypeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKPlaidConfigurationErrorTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKPlaidFinanceKitTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKPlaidHeadlessSessionTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKPlaidLayerSessionTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKPlaidLinkSessionTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKRateLimitErrorCodeTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKSuccessMetadataTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKVerificationStatusTests.swift create mode 100644 Tests/LinkKitObjCTests/PLKViewNameTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bfcaa4..3a38e5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: jobs: build: - name: Build Swift Package + name: Build And Test Swift Package runs-on: macos-15 steps: @@ -21,3 +21,22 @@ jobs: - name: Build package run: xcodebuild build -scheme LinkKit -destination 'generic/platform=iOS' -derivedDataPath .build/DerivedData + + - name: Run ObjC compatibility tests + run: | + xcodebuild test \ + -scheme LinkKit-Package \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \ + -derivedDataPath .build/ObjCTestsDerivedData \ + -clonedSourcePackagesDirPath .build/SourcePackages \ + CODE_SIGNING_ALLOWED=NO + + - name: Build ObjC demo app + run: | + xcodebuild build \ + -project LinkDemo-ObjC/LinkDemo-ObjC.xcodeproj \ + -scheme LinkDemo-ObjC \ + -destination 'generic/platform=iOS' \ + -derivedDataPath .build/ObjCDemoDerivedData \ + -clonedSourcePackagesDirPath .build/SourcePackages \ + CODE_SIGNING_ALLOWED=NO diff --git a/LinkDemo-ObjC/LinkDemo-ObjC.xcodeproj/project.pbxproj b/LinkDemo-ObjC/LinkDemo-ObjC.xcodeproj/project.pbxproj new file mode 100644 index 0000000..705a699 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC.xcodeproj/project.pbxproj @@ -0,0 +1,371 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 7C5D17152F522B9E00018D4E /* LinkKitObjC in Frameworks */ = {isa = PBXBuildFile; productRef = 7C5D17142F522B9E00018D4E /* LinkKitObjC */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 7C5D16F52F522B6200018D4E /* LinkDemo-ObjC.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "LinkDemo-ObjC.app"; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7C5D170C2F522B6400018D4E /* Exceptions for "LinkDemo-ObjC" folder in "LinkDemo-ObjC" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 7C5D16F42F522B6200018D4E /* LinkDemo-ObjC */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 7C5D16F72F522B6200018D4E /* LinkDemo-ObjC */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 7C5D170C2F522B6400018D4E /* Exceptions for "LinkDemo-ObjC" folder in "LinkDemo-ObjC" target */, + ); + path = "LinkDemo-ObjC"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 7C5D16F22F522B6200018D4E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7C5D17152F522B9E00018D4E /* LinkKitObjC in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 7C5D16EC2F522B6200018D4E = { + isa = PBXGroup; + children = ( + 7C5D16F72F522B6200018D4E /* LinkDemo-ObjC */, + 7C5D16F62F522B6200018D4E /* Products */, + ); + sourceTree = ""; + }; + 7C5D16F62F522B6200018D4E /* Products */ = { + isa = PBXGroup; + children = ( + 7C5D16F52F522B6200018D4E /* LinkDemo-ObjC.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 7C5D16F42F522B6200018D4E /* LinkDemo-ObjC */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7C5D170D2F522B6400018D4E /* Build configuration list for PBXNativeTarget "LinkDemo-ObjC" */; + buildPhases = ( + 7C5D16F12F522B6200018D4E /* Sources */, + 7C5D16F22F522B6200018D4E /* Frameworks */, + 7C5D16F32F522B6200018D4E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7C5D16F72F522B6200018D4E /* LinkDemo-ObjC */, + ); + name = "LinkDemo-ObjC"; + packageProductDependencies = ( + 7C5D17142F522B9E00018D4E /* LinkKitObjC */, + ); + productName = "LinkDemo-ObjC"; + productReference = 7C5D16F52F522B6200018D4E /* LinkDemo-ObjC.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 7C5D16ED2F522B6200018D4E /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastUpgradeCheck = 2630; + TargetAttributes = { + 7C5D16F42F522B6200018D4E = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = 7C5D16F02F522B6200018D4E /* Build configuration list for PBXProject "LinkDemo-ObjC" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 7C5D16EC2F522B6200018D4E; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 7C5D17132F522B9E00018D4E /* XCLocalSwiftPackageReference ".." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 7C5D16F62F522B6200018D4E /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 7C5D16F42F522B6200018D4E /* LinkDemo-ObjC */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 7C5D16F32F522B6200018D4E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 7C5D16F12F522B6200018D4E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 7C5D170E2F522B6400018D4E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "LinkDemo-ObjC/LinkDemo-ObjC.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Z2P2MSMY87; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "LinkDemo-ObjC/Info.plist"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.plaid.LinkDemo-ObjC"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 7C5D170F2F522B6400018D4E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "LinkDemo-ObjC/LinkDemo-ObjC.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = Z2P2MSMY87; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "LinkDemo-ObjC/Info.plist"; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.plaid.LinkDemo-ObjC"; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 7C5D17102F522B6400018D4E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = Z2P2MSMY87; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 7C5D17112F522B6400018D4E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = Z2P2MSMY87; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 7C5D16F02F522B6200018D4E /* Build configuration list for PBXProject "LinkDemo-ObjC" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7C5D17102F522B6400018D4E /* Debug */, + 7C5D17112F522B6400018D4E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7C5D170D2F522B6400018D4E /* Build configuration list for PBXNativeTarget "LinkDemo-ObjC" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7C5D170E2F522B6400018D4E /* Debug */, + 7C5D170F2F522B6400018D4E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 7C5D17132F522B9E00018D4E /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7C5D17142F522B9E00018D4E /* LinkKitObjC */ = { + isa = XCSwiftPackageProductDependency; + productName = LinkKitObjC; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 7C5D16ED2F522B6200018D4E /* Project object */; +} diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.h b/LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.h new file mode 100644 index 0000000..a877663 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.h @@ -0,0 +1,14 @@ +// +// AppDelegate.h +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import + +@interface AppDelegate : UIResponder + + +@end + diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.m b/LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.m new file mode 100644 index 0000000..35f0da4 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/App/AppDelegate.m @@ -0,0 +1,40 @@ +// +// AppDelegate.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import "AppDelegate.h" + +@interface AppDelegate () + +@end + +@implementation AppDelegate + + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + // Override point for customization after application launch. + return YES; +} + + +#pragma mark - UISceneSession lifecycle + + +- (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return [[UISceneConfiguration alloc] initWithName:@"Default Configuration" sessionRole:connectingSceneSession.role]; +} + + +- (void)application:(UIApplication *)application didDiscardSceneSessions:(NSSet *)sceneSessions { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. +} + + +@end diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/App/SceneDelegate.h b/LinkDemo-ObjC/LinkDemo-ObjC/App/SceneDelegate.h new file mode 100644 index 0000000..9ae8c1f --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/App/SceneDelegate.h @@ -0,0 +1,15 @@ +// +// SceneDelegate.h +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import + +@interface SceneDelegate : UIResponder + +@property (strong, nonatomic) UIWindow * window; + +@end + diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/App/SceneDelegate.m b/LinkDemo-ObjC/LinkDemo-ObjC/App/SceneDelegate.m new file mode 100644 index 0000000..0cb0171 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/App/SceneDelegate.m @@ -0,0 +1,32 @@ +// +// SceneDelegate.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import "SceneDelegate.h" +#import "ExampleListViewController.h" + +@interface SceneDelegate () + +@end + +@implementation SceneDelegate + + +- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions { + // Create the window with the UIWindowScene + UIWindowScene *windowScene = (UIWindowScene *)scene; + self.window = [[UIWindow alloc] initWithWindowScene:windowScene]; + + // Create the root view controller + ExampleListViewController *exampleListVC = [[ExampleListViewController alloc] init]; + UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:exampleListVC]; + + // Set the root view controller and make the window visible + self.window.rootViewController = navigationController; + [self.window makeKeyAndVisible]; +} + +@end diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/App/main.m b/LinkDemo-ObjC/LinkDemo-ObjC/App/main.m new file mode 100644 index 0000000..716f211 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/App/main.m @@ -0,0 +1,18 @@ +// +// main.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import +#import "AppDelegate.h" + +int main(int argc, char * argv[]) { + NSString * appDelegateClassName; + @autoreleasepool { + // Setup code that might create autoreleased objects goes here. + appDelegateClassName = NSStringFromClass([AppDelegate class]); + } + return UIApplicationMain(argc, argv, nil, appDelegateClassName); +} diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/AccentColor.colorset/Contents.json b/LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/AppIcon.appiconset/Contents.json b/LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/Contents.json b/LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/Base.lproj/LaunchScreen.storyboard b/LinkDemo-ObjC/LinkDemo-ObjC/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/Base.lproj/Main.storyboard b/LinkDemo-ObjC/LinkDemo-ObjC/Base.lproj/Main.storyboard new file mode 100644 index 0000000..808a21c --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/Base.lproj/Main.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ExampleListViewController.h b/LinkDemo-ObjC/LinkDemo-ObjC/ExampleListViewController.h new file mode 100644 index 0000000..02ef863 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ExampleListViewController.h @@ -0,0 +1,16 @@ +// +// ExampleListViewController.h +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ExampleListViewController : UIViewController + +@end + +NS_ASSUME_NONNULL_END diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ExampleListViewController.m b/LinkDemo-ObjC/LinkDemo-ObjC/ExampleListViewController.m new file mode 100644 index 0000000..dd6364e --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ExampleListViewController.m @@ -0,0 +1,114 @@ +// +// ExampleListViewController.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import "ExampleListViewController.h" +#import "ExampleView.h" +#import "ViewControllers/LinkSession/PlaidLinkSessionViewController.h" +#import "ViewControllers/HeadlessSession/PlaidLinkHeadlessSessionViewController.h" +#import "ViewControllers/LayerSession/PlaidLayerSessionViewController.h" +#import "ViewControllers/EmbeddedSearch/PlaidEmbeddedLinkViewController.h" +#import "ViewControllers/FinanceKit/SyncFinanceKitViewController.h" + +static NSString *const kCellReuseIdentifier = @"ExampleCell"; + +@interface ExampleListViewController () + +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) NSArray *examples; +@property (nonatomic, copy) NSString *linkToken; + +@end + +@implementation ExampleListViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.title = @"LinkKit Examples"; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + + #warning Replace <#GENERATED_LINK_TOKEN#> below with your link_token + self.linkToken = @"<#GENERATED_LINK_TOKEN#>"; + + self.examples = [ExampleView allExamples]; + + [self setupTableView]; +} + +- (void)setupTableView { + self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleInsetGrouped]; + self.tableView.delegate = self; + self.tableView.dataSource = self; + self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + + [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:kCellReuseIdentifier]; + + [self.view addSubview:self.tableView]; +} + +#pragma mark - UITableViewDataSource + +- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { + return self.examples.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { + UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:kCellReuseIdentifier forIndexPath:indexPath]; + + ExampleView *example = self.examples[indexPath.row]; + + // Configure cell + UIListContentConfiguration *content = [cell defaultContentConfiguration]; + content.text = example.title; + content.secondaryText = example.exampleDescription; + content.secondaryTextProperties.numberOfLines = 0; + content.secondaryTextProperties.font = [UIFont preferredFontForTextStyle:UIFontTextStyleCaption1]; + content.secondaryTextProperties.color = [UIColor secondaryLabelColor]; + + cell.contentConfiguration = content; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + + return cell; +} + +#pragma mark - UITableViewDelegate + +- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tableView deselectRowAtIndexPath:indexPath animated:YES]; + + ExampleView *example = self.examples[indexPath.row]; + + UIViewController *viewController = nil; + + switch (example.type) { + case ExampleViewTypePlaidLinkSession: + viewController = [[PlaidLinkSessionViewController alloc] initWithLinkToken:self.linkToken]; + break; + + case ExampleViewTypePlaidLinkHeadlessSession: + viewController = [[PlaidLinkHeadlessSessionViewController alloc] initWithLinkToken:self.linkToken]; + break; + + case ExampleViewTypePlaidLayerSession: + viewController = [[PlaidLayerSessionViewController alloc] initWithLinkToken:self.linkToken]; + break; + + case ExampleViewTypePlaidEmbeddedSearch: + viewController = [[PlaidEmbeddedLinkViewController alloc] initWithLinkToken:self.linkToken]; + break; + + case ExampleViewTypeSyncFinanceKit: + viewController = [[SyncFinanceKitViewController alloc] initWithLinkToken:self.linkToken]; + break; + } + + if (viewController) { + [self.navigationController pushViewController:viewController animated:YES]; + } +} + +@end diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/Info.plist b/LinkDemo-ObjC/LinkDemo-ObjC/Info.plist new file mode 100644 index 0000000..81ed29b --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/Info.plist @@ -0,0 +1,25 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/LinkDemo-ObjC.entitlements b/LinkDemo-ObjC/LinkDemo-ObjC/LinkDemo-ObjC.entitlements new file mode 100644 index 0000000..156b442 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/LinkDemo-ObjC.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.associated-domains + + applinks:YOUR_OAUTH_REDIRECT_URI_DOMAIN_NAME + + + diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/Models/ExampleView.h b/LinkDemo-ObjC/LinkDemo-ObjC/Models/ExampleView.h new file mode 100644 index 0000000..fe9f423 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/Models/ExampleView.h @@ -0,0 +1,31 @@ +// +// ExampleView.h +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, ExampleViewType) { + ExampleViewTypePlaidLinkSession, + ExampleViewTypePlaidLinkHeadlessSession, + ExampleViewTypePlaidLayerSession, + ExampleViewTypePlaidEmbeddedSearch, + ExampleViewTypeSyncFinanceKit, +}; + +@interface ExampleView : NSObject + +@property (nonatomic, readonly) ExampleViewType type; +@property (nonatomic, readonly, copy) NSString *title; +@property (nonatomic, readonly, copy) NSString *exampleDescription; + ++ (instancetype)exampleWithType:(ExampleViewType)type; ++ (NSArray *)allExamples; + +@end + +NS_ASSUME_NONNULL_END diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/Models/ExampleView.m b/LinkDemo-ObjC/LinkDemo-ObjC/Models/ExampleView.m new file mode 100644 index 0000000..51c525c --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/Models/ExampleView.m @@ -0,0 +1,64 @@ +// +// ExampleView.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import "ExampleView.h" + +@implementation ExampleView + +- (instancetype)initWithType:(ExampleViewType)type { + self = [super init]; + if (self) { + _type = type; + } + return self; +} + ++ (instancetype)exampleWithType:(ExampleViewType)type { + return [[self alloc] initWithType:type]; +} + ++ (NSArray *)allExamples { + return @[ + [ExampleView exampleWithType:ExampleViewTypePlaidLinkSession], + [ExampleView exampleWithType:ExampleViewTypePlaidLinkHeadlessSession], + [ExampleView exampleWithType:ExampleViewTypePlaidLayerSession], + [ExampleView exampleWithType:ExampleViewTypePlaidEmbeddedSearch], + [ExampleView exampleWithType:ExampleViewTypeSyncFinanceKit], + ]; +} + +- (NSString *)title { + switch (self.type) { + case ExampleViewTypePlaidLinkSession: + return @"PlaidLinkSession"; + case ExampleViewTypePlaidLinkHeadlessSession: + return @"PlaidLinkHeadlessSession"; + case ExampleViewTypePlaidLayerSession: + return @"PlaidLayerSession"; + case ExampleViewTypePlaidEmbeddedSearch: + return @"PlaidEmbeddedSearch"; + case ExampleViewTypeSyncFinanceKit: + return @"Sync FinanceKit"; + } +} + +- (NSString *)exampleDescription { + switch (self.type) { + case ExampleViewTypePlaidLinkSession: + return @"Shows how create a PlaidLinkSession and use openWithPresentationHandler."; + case ExampleViewTypePlaidLinkHeadlessSession: + return @"Shows how to create a PlaidHeadlessSession and start a payment flow."; + case ExampleViewTypePlaidLayerSession: + return @"Shows how to create a PlaidLayerSession and start a Layer flow using openWithPresentationHandler."; + case ExampleViewTypePlaidEmbeddedSearch: + return @"Shows how to create a EmbeddedSearchView and start a Link flow from it."; + case ExampleViewTypeSyncFinanceKit: + return @"Shows how to sync the latest transactions from Apple Card using FinanceKit."; + } +} + +@end diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/EmbeddedSearch/PlaidEmbeddedLinkViewController.h b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/EmbeddedSearch/PlaidEmbeddedLinkViewController.h new file mode 100644 index 0000000..a7b3e57 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/EmbeddedSearch/PlaidEmbeddedLinkViewController.h @@ -0,0 +1,18 @@ +// +// PlaidEmbeddedLinkViewController.h +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface PlaidEmbeddedLinkViewController : UIViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken; + +@end + +NS_ASSUME_NONNULL_END diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/EmbeddedSearch/PlaidEmbeddedLinkViewController.m b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/EmbeddedSearch/PlaidEmbeddedLinkViewController.m new file mode 100644 index 0000000..de30d33 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/EmbeddedSearch/PlaidEmbeddedLinkViewController.m @@ -0,0 +1,194 @@ +// +// PlaidEmbeddedLinkViewController.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import "PlaidEmbeddedLinkViewController.h" +#import "LinkKitObjectiveC.h" +@import LinkKitObjC; + +typedef NS_ENUM(NSInteger, LoadingState) { + LoadingStateLoading, + LoadingStateReady, + LoadingStateError +}; + +@interface PlaidEmbeddedLinkViewController () + +@property (nonatomic, copy) NSString *linkToken; +@property (nonatomic, assign) LoadingState state; +@property (nonatomic, copy, nullable) NSString *errorMessage; +@property (nonatomic, strong, nullable) UIView *embeddedSearchView; + +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *versionLabel; +@property (nonatomic, strong) UILabel *errorLabel; +@property (nonatomic, strong) UIView *embeddedViewContainer; + +@end + +@implementation PlaidEmbeddedLinkViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _linkToken = [linkToken copy]; + _state = LoadingStateLoading; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.title = @"Plaid Embedded Link"; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + + [self setupUI]; + [self createEmbeddedView]; +} + +- (void)setupUI { + UIScrollView *scrollView = [[UIScrollView alloc] init]; + scrollView.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:scrollView]; + + UIStackView *stackView = [[UIStackView alloc] init]; + stackView.axis = UILayoutConstraintAxisVertical; + stackView.spacing = 16; + stackView.translatesAutoresizingMaskIntoConstraints = NO; + [scrollView addSubview:stackView]; + + self.titleLabel = [[UILabel alloc] init]; + self.titleLabel.text = @"Plaid Embedded Link View Example"; + self.titleLabel.font = [UIFont boldSystemFontOfSize:20]; + self.titleLabel.textAlignment = NSTextAlignmentCenter; + self.titleLabel.numberOfLines = 0; + [stackView addArrangedSubview:self.titleLabel]; + + self.versionLabel = [[UILabel alloc] init]; + NSString *version = [PLKPlaid sdkVersion]; + self.versionLabel.text = [NSString stringWithFormat:@"LinkKit v%@", version]; + self.versionLabel.font = [UIFont systemFontOfSize:14]; + self.versionLabel.textColor = [UIColor secondaryLabelColor]; + self.versionLabel.textAlignment = NSTextAlignmentCenter; + [stackView addArrangedSubview:self.versionLabel]; + + self.errorLabel = [[UILabel alloc] init]; + self.errorLabel.font = [UIFont systemFontOfSize:14]; + self.errorLabel.textColor = [UIColor systemRedColor]; + self.errorLabel.textAlignment = NSTextAlignmentCenter; + self.errorLabel.numberOfLines = 0; + self.errorLabel.hidden = YES; + [stackView addArrangedSubview:self.errorLabel]; + + self.embeddedViewContainer = [[UIView alloc] init]; + self.embeddedViewContainer.translatesAutoresizingMaskIntoConstraints = NO; + [stackView addArrangedSubview:self.embeddedViewContainer]; + + [NSLayoutConstraint activateConstraints:@[ + [scrollView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], + [scrollView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [scrollView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [scrollView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + + [stackView.topAnchor constraintEqualToAnchor:scrollView.topAnchor constant:16], + [stackView.leadingAnchor constraintEqualToAnchor:scrollView.leadingAnchor constant:16], + [stackView.trailingAnchor constraintEqualToAnchor:scrollView.trailingAnchor constant:-16], + [stackView.bottomAnchor constraintEqualToAnchor:scrollView.bottomAnchor constant:-16], + [stackView.widthAnchor constraintEqualToAnchor:scrollView.widthAnchor constant:-32], + ]]; +} + +- (void)createEmbeddedView { + __weak typeof(self) weakSelf = self; + + PLKEmbeddedLinkTokenConfiguration *configuration = [PLKEmbeddedLinkTokenConfiguration createWithToken:self.linkToken onSuccess:^(PLKLinkSuccess *success) { + NSLog(@"public-token: %@ metadata: %@", success.publicToken, success.metadata.metadataJSON); + }]; + + configuration.onExit = ^(PLKLinkExit *exit) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + + if (exit.error) { + NSLog(@"exit with %@\n%@", exit.error, exit.metadata.metadataJSON); + [strongSelf setState:LoadingStateError]; + NSString *displayMessage = exit.error.userInfo[kPLKExitErrorDisplayMessageKey]; + strongSelf.errorMessage = displayMessage ?: exit.error.localizedDescription ?: @"Exited with error"; + [strongSelf updateUI]; + } else { + NSLog(@"exit with %@", exit.metadata.metadataJSON); + } + }; + + configuration.onEvent = ^(PLKLinkEvent *event) { + NSLog(@"Link Event: %@", event.eventName); + }; + + NSError *error = nil; + UIView *embeddedView = [PLKPlaid createEmbeddedLinkUIViewWithConfiguration:configuration + error:&error + presentationHandler:^(UIViewController *viewController) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf presentViewController:viewController animated:YES completion:nil]; + } + } + dismissalHandler:^(UIViewController *viewController) { + [viewController dismissViewControllerAnimated:YES completion:nil]; + }]; + + if (error) { + self.state = LoadingStateError; + self.errorMessage = error.localizedDescription; + [self updateUI]; + } else if (embeddedView) { + self.embeddedSearchView = embeddedView; + self.state = LoadingStateReady; + [self addEmbeddedView]; + [self updateUI]; + } +} + +- (void)addEmbeddedView { + if (!self.embeddedSearchView) return; + self.embeddedSearchView.translatesAutoresizingMaskIntoConstraints = NO; + [self.embeddedViewContainer addSubview:self.embeddedSearchView]; + + [NSLayoutConstraint activateConstraints:@[ + [self.embeddedSearchView.topAnchor constraintEqualToAnchor:self.embeddedViewContainer.topAnchor], + [self.embeddedSearchView.leadingAnchor constraintEqualToAnchor:self.embeddedViewContainer.leadingAnchor], + [self.embeddedSearchView.trailingAnchor constraintEqualToAnchor:self.embeddedViewContainer.trailingAnchor], + [self.embeddedSearchView.bottomAnchor constraintEqualToAnchor:self.embeddedViewContainer.bottomAnchor], + + // Set a minimum height for the embedded view + [self.embeddedSearchView.heightAnchor constraintGreaterThanOrEqualToConstant:400] + ]]; +} + +- (void)updateUI { + dispatch_async(dispatch_get_main_queue(), ^{ + switch (self.state) { + case LoadingStateLoading: + self.errorLabel.hidden = YES; + self.embeddedViewContainer.hidden = YES; + break; + + case LoadingStateReady: + self.errorLabel.hidden = YES; + self.embeddedViewContainer.hidden = NO; + break; + + case LoadingStateError: + self.errorLabel.text = self.errorMessage; + self.errorLabel.hidden = NO; + self.embeddedViewContainer.hidden = YES; + break; + } + }); +} + +@end diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/FinanceKit/SyncFinanceKitViewController.h b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/FinanceKit/SyncFinanceKitViewController.h new file mode 100644 index 0000000..7278df2 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/FinanceKit/SyncFinanceKitViewController.h @@ -0,0 +1,18 @@ +// +// SyncFinanceKitViewController.h +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface SyncFinanceKitViewController : UIViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken; + +@end + +NS_ASSUME_NONNULL_END diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/FinanceKit/SyncFinanceKitViewController.m b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/FinanceKit/SyncFinanceKitViewController.m new file mode 100644 index 0000000..567ace5 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/FinanceKit/SyncFinanceKitViewController.m @@ -0,0 +1,167 @@ +// +// SyncFinanceKitViewController.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import "SyncFinanceKitViewController.h" +#import "LinkKitObjectiveC.h" +@import LinkKitObjC; + +@interface SyncFinanceKitViewController () + +@property (nonatomic, copy) NSString *linkToken; +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *versionLabel; +@property (nonatomic, strong) UILabel *warningLabel; +@property (nonatomic, strong) UILabel *requirementsLabel; +@property (nonatomic, strong) UIButton *syncButton; +@property (nonatomic, strong) UILabel *statusLabel; +@property (nonatomic, strong) UIActivityIndicatorView *activityIndicator; + +@end + +@implementation SyncFinanceKitViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _linkToken = [linkToken copy]; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.title = @"Sync FinanceKit"; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + + [self setupUI]; +} + +- (void)setupUI { + UIScrollView *scrollView = [[UIScrollView alloc] init]; + scrollView.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:scrollView]; + + UIStackView *stackView = [[UIStackView alloc] init]; + stackView.axis = UILayoutConstraintAxisVertical; + stackView.spacing = 16; + stackView.translatesAutoresizingMaskIntoConstraints = NO; + [scrollView addSubview:stackView]; + + // Title Label + self.titleLabel = [[UILabel alloc] init]; + self.titleLabel.text = @"Sync FinanceKit Example"; + self.titleLabel.font = [UIFont boldSystemFontOfSize:20]; + self.titleLabel.textAlignment = NSTextAlignmentCenter; + self.titleLabel.numberOfLines = 0; + [stackView addArrangedSubview:self.titleLabel]; + + // Version Label + self.versionLabel = [[UILabel alloc] init]; + NSString *version = [PLKPlaid sdkVersion]; + self.versionLabel.text = [NSString stringWithFormat:@"LinkKit v%@", version]; + self.versionLabel.font = [UIFont systemFontOfSize:14]; + self.versionLabel.textColor = [UIColor secondaryLabelColor]; + self.versionLabel.textAlignment = NSTextAlignmentCenter; + [stackView addArrangedSubview:self.versionLabel]; + + self.warningLabel = [[UILabel alloc] init]; + self.warningLabel.text = @"⚠️ IMPORTANT"; + self.warningLabel.font = [UIFont boldSystemFontOfSize:16]; + self.warningLabel.textColor = [UIColor systemOrangeColor]; + self.warningLabel.textAlignment = NSTextAlignmentCenter; + [stackView addArrangedSubview:self.warningLabel]; + + self.requirementsLabel = [[UILabel alloc] init]; + self.requirementsLabel.text = @"Requirements:\n\n1. You MUST have a FinanceKit entitlement from Apple (or the app will crash)\n\n2. The link token must be associated with an access token from a previous session where the user linked their Apple Card\n\n3. iOS 17.4 or later is required"; + self.requirementsLabel.font = [UIFont systemFontOfSize:14]; + self.requirementsLabel.textColor = [UIColor labelColor]; + self.requirementsLabel.numberOfLines = 0; + self.requirementsLabel.textAlignment = NSTextAlignmentLeft; + [stackView addArrangedSubview:self.requirementsLabel]; + + self.syncButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [self.syncButton setTitle:@"Sync FinanceKit" forState:UIControlStateNormal]; + self.syncButton.titleLabel.font = [UIFont boldSystemFontOfSize:16]; + [self.syncButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + self.syncButton.backgroundColor = [UIColor systemBlueColor]; + self.syncButton.layer.cornerRadius = 10; + [self.syncButton addTarget:self action:@selector(syncButtonTapped) forControlEvents:UIControlEventTouchUpInside]; + [stackView addArrangedSubview:self.syncButton]; + + self.activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + self.activityIndicator.color = [UIColor whiteColor]; + self.activityIndicator.hidesWhenStopped = YES; + self.activityIndicator.translatesAutoresizingMaskIntoConstraints = NO; + [self.syncButton addSubview:self.activityIndicator]; + + self.statusLabel = [[UILabel alloc] init]; + self.statusLabel.font = [UIFont systemFontOfSize:14]; + self.statusLabel.textColor = [UIColor secondaryLabelColor]; + self.statusLabel.textAlignment = NSTextAlignmentCenter; + self.statusLabel.numberOfLines = 0; + self.statusLabel.hidden = YES; + [stackView addArrangedSubview:self.statusLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [scrollView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], + [scrollView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [scrollView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [scrollView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + + [stackView.topAnchor constraintEqualToAnchor:scrollView.topAnchor constant:16], + [stackView.leadingAnchor constraintEqualToAnchor:scrollView.leadingAnchor constant:16], + [stackView.trailingAnchor constraintEqualToAnchor:scrollView.trailingAnchor constant:-16], + [stackView.bottomAnchor constraintEqualToAnchor:scrollView.bottomAnchor constant:-16], + [stackView.widthAnchor constraintEqualToAnchor:scrollView.widthAnchor constant:-32], + + [self.syncButton.heightAnchor constraintEqualToConstant:56], + [self.activityIndicator.leadingAnchor constraintEqualToAnchor:self.syncButton.leadingAnchor constant:16], + [self.activityIndicator.centerYAnchor constraintEqualToAnchor:self.syncButton.centerYAnchor] + ]]; +} + +- (void)syncButtonTapped { + if (@available(iOS 17.4, *)) { + [self syncFinanceKit]; + } else { + [self showStatus:@"❌ FinanceKit requires iOS 17.4 or later" isError:YES]; + } +} + +- (void)syncFinanceKit API_AVAILABLE(ios(17.4)) { + self.syncButton.enabled = NO; + [self.activityIndicator startAnimating]; + self.statusLabel.hidden = YES; + + [PLKPlaidFinanceKit syncFinanceKitWithToken:self.linkToken + requestAuthorizationIfNeeded:NO + syncBehavior:PLKSyncBehaviorLive + onSuccess:^{ + dispatch_async(dispatch_get_main_queue(), ^{ + [self showStatus:@"✅ Sync completed successfully" isError:NO]; + [self.activityIndicator stopAnimating]; + self.syncButton.enabled = YES; + }); + } + onError:^(NSError *error) { + dispatch_async(dispatch_get_main_queue(), ^{ + NSString *errorMessage = [NSString stringWithFormat:@"❌ Sync failed: %@", error.localizedDescription]; + [self showStatus:errorMessage isError:YES]; + [self.activityIndicator stopAnimating]; + self.syncButton.enabled = YES; + }); + }]; +} + +- (void)showStatus:(NSString *)message isError:(BOOL)isError { + self.statusLabel.text = message; + self.statusLabel.textColor = isError ? [UIColor systemRedColor] : [UIColor systemGreenColor]; + self.statusLabel.hidden = NO; +} + +@end diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/HeadlessSession/PlaidLinkHeadlessSessionViewController.h b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/HeadlessSession/PlaidLinkHeadlessSessionViewController.h new file mode 100644 index 0000000..a44004b --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/HeadlessSession/PlaidLinkHeadlessSessionViewController.h @@ -0,0 +1,18 @@ +// +// PlaidLinkHeadlessSessionViewController.h +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface PlaidLinkHeadlessSessionViewController : UIViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken; + +@end + +NS_ASSUME_NONNULL_END diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/HeadlessSession/PlaidLinkHeadlessSessionViewController.m b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/HeadlessSession/PlaidLinkHeadlessSessionViewController.m new file mode 100644 index 0000000..bbd545f --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/HeadlessSession/PlaidLinkHeadlessSessionViewController.m @@ -0,0 +1,202 @@ +// +// PlaidLinkHeadlessSessionViewController.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import "PlaidLinkHeadlessSessionViewController.h" +#import "LinkKitObjectiveC.h" +@import LinkKitObjC; + +typedef NS_ENUM(NSInteger, LoadingState) { + LoadingStateLoading, + LoadingStateReady, + LoadingStateError +}; + +@interface PlaidLinkHeadlessSessionViewController () + +@property (nonatomic, copy) NSString *linkToken; +@property (nonatomic, assign) LoadingState state; +@property (nonatomic, copy, nullable) NSString *errorMessage; +@property (nonatomic, strong, nullable) id headlessSession; + +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *versionLabel; +@property (nonatomic, strong) UILabel *errorLabel; +@property (nonatomic, strong) UIButton *launchButton; +@property (nonatomic, strong) UIActivityIndicatorView *activityIndicator; + +@end + +@implementation PlaidLinkHeadlessSessionViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _linkToken = [linkToken copy]; + _state = LoadingStateLoading; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.title = @"Plaid Headless Session"; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + + [self setupUI]; + [self createSession]; +} + +- (void)setupUI { + self.titleLabel = [[UILabel alloc] init]; + self.titleLabel.text = @"Plaid Link Headless Session Example"; + self.titleLabel.font = [UIFont boldSystemFontOfSize:20]; + self.titleLabel.textAlignment = NSTextAlignmentCenter; + self.titleLabel.numberOfLines = 0; + self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.titleLabel]; + + self.versionLabel = [[UILabel alloc] init]; + NSString *version = [PLKPlaid sdkVersion]; + self.versionLabel.text = [NSString stringWithFormat:@"LinkKit v%@", version]; + self.versionLabel.font = [UIFont systemFontOfSize:14]; + self.versionLabel.textColor = [UIColor secondaryLabelColor]; + self.versionLabel.textAlignment = NSTextAlignmentCenter; + self.versionLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.versionLabel]; + + self.errorLabel = [[UILabel alloc] init]; + self.errorLabel.font = [UIFont systemFontOfSize:14]; + self.errorLabel.textColor = [UIColor systemRedColor]; + self.errorLabel.textAlignment = NSTextAlignmentCenter; + self.errorLabel.numberOfLines = 0; + self.errorLabel.hidden = YES; + self.errorLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.errorLabel]; + + self.launchButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [self.launchButton setTitle:@"Launch Payment Flow" forState:UIControlStateNormal]; + self.launchButton.titleLabel.font = [UIFont boldSystemFontOfSize:16]; + [self.launchButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + self.launchButton.backgroundColor = [UIColor systemBlueColor]; + self.launchButton.layer.cornerRadius = 10; + self.launchButton.translatesAutoresizingMaskIntoConstraints = NO; + [self.launchButton addTarget:self action:@selector(launchButtonTapped) forControlEvents:UIControlEventTouchUpInside]; + [self.view addSubview:self.launchButton]; + + self.activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + self.activityIndicator.color = [UIColor whiteColor]; + self.activityIndicator.hidesWhenStopped = YES; + self.activityIndicator.translatesAutoresizingMaskIntoConstraints = NO; + [self.launchButton addSubview:self.activityIndicator]; + + [NSLayoutConstraint activateConstraints:@[ + [self.titleLabel.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:32], + [self.titleLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:16], + [self.titleLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16], + + [self.versionLabel.topAnchor constraintEqualToAnchor:self.titleLabel.bottomAnchor constant:8], + [self.versionLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:16], + [self.versionLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16], + + [self.errorLabel.topAnchor constraintEqualToAnchor:self.versionLabel.bottomAnchor constant:16], + [self.errorLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:16], + [self.errorLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16], + + [self.launchButton.topAnchor constraintEqualToAnchor:self.errorLabel.bottomAnchor constant:24], + [self.launchButton.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:16], + [self.launchButton.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16], + [self.launchButton.heightAnchor constraintEqualToConstant:56], + + [self.activityIndicator.leadingAnchor constraintEqualToAnchor:self.launchButton.leadingAnchor constant:16], + [self.activityIndicator.centerYAnchor constraintEqualToAnchor:self.launchButton.centerYAnchor] + ]]; + + [self updateUI]; +} + +- (void)createSession { + __weak typeof(self) weakSelf = self; + + PLKLinkTokenConfiguration *configuration = [PLKLinkTokenConfiguration createWithToken:self.linkToken onSuccess:^(PLKLinkSuccess *success) { + NSLog(@"public-token: %@ metadata: %@", success.publicToken, success.metadata.metadataJSON); + }]; + + configuration.onExit = ^(PLKLinkExit *exit) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + + if (exit.error) { + NSLog(@"exit with %@\n%@", exit.error, exit.metadata.metadataJSON); + [strongSelf setState:LoadingStateError]; + NSString *displayMessage = exit.error.userInfo[kPLKExitErrorDisplayMessageKey]; + strongSelf.errorMessage = displayMessage ?: exit.error.localizedDescription ?: @"Exited with error"; + [strongSelf updateUI]; + } else { + NSLog(@"exit with %@", exit.metadata.metadataJSON); + } + }; + + configuration.onEvent = ^(PLKLinkEvent *event) { + NSLog(@"Link Event: %@", event.eventName); + }; + + configuration.onLoad = ^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + + [strongSelf setState:LoadingStateReady]; + [strongSelf updateUI]; + }; + + NSError *error = nil; + id session = [PLKPlaid createHeadlessSessionWithConfiguration:configuration error:&error]; + + if (error) { + self.state = LoadingStateError; + self.errorMessage = error.localizedDescription; + [self updateUI]; + } else { + self.headlessSession = session; + } +} + +- (void)launchButtonTapped { + if (self.headlessSession) { + [self.headlessSession start]; + } +} + +- (void)updateUI { + dispatch_async(dispatch_get_main_queue(), ^{ + switch (self.state) { + case LoadingStateLoading: + self.launchButton.enabled = NO; + self.launchButton.backgroundColor = [UIColor systemGrayColor]; + [self.activityIndicator startAnimating]; + self.errorLabel.hidden = YES; + break; + + case LoadingStateReady: + self.launchButton.enabled = YES; + self.launchButton.backgroundColor = [UIColor systemBlueColor]; + [self.activityIndicator stopAnimating]; + self.errorLabel.hidden = YES; + break; + + case LoadingStateError: + self.launchButton.enabled = NO; + self.launchButton.backgroundColor = [UIColor systemGrayColor]; + [self.activityIndicator stopAnimating]; + self.errorLabel.text = self.errorMessage; + self.errorLabel.hidden = NO; + break; + } + }); +} + +@end diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LayerSession/PlaidLayerSessionViewController.h b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LayerSession/PlaidLayerSessionViewController.h new file mode 100644 index 0000000..0beeb56 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LayerSession/PlaidLayerSessionViewController.h @@ -0,0 +1,18 @@ +// +// PlaidLayerSessionViewController.h +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface PlaidLayerSessionViewController : UIViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken; + +@end + +NS_ASSUME_NONNULL_END diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LayerSession/PlaidLayerSessionViewController.m b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LayerSession/PlaidLayerSessionViewController.m new file mode 100644 index 0000000..7557a0c --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LayerSession/PlaidLayerSessionViewController.m @@ -0,0 +1,274 @@ +// +// PlaidLayerSessionViewController.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import "PlaidLayerSessionViewController.h" +#import "LinkKitObjectiveC.h" +@import LinkKitObjC; + +typedef NS_ENUM(NSInteger, LoadingState) { + LoadingStateLoading, + LoadingStateReady, + LoadingStateError +}; + +@interface PlaidLayerSessionViewController () + +@property (nonatomic, copy) NSString *linkToken; +@property (nonatomic, assign) LoadingState state; +@property (nonatomic, copy, nullable) NSString *errorMessage; +@property (nonatomic, strong, nullable) id layerSession; + +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *versionLabel; +@property (nonatomic, strong) UILabel *errorLabel; +@property (nonatomic, strong) UITextField *phoneNumberTextField; +@property (nonatomic, strong) UITextField *dobTextField; +@property (nonatomic, strong) UIButton *submitButton; +@property (nonatomic, strong) UIButton *launchButton; +@property (nonatomic, strong) UIActivityIndicatorView *activityIndicator; + +@end + +@implementation PlaidLayerSessionViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _linkToken = [linkToken copy]; + _state = LoadingStateLoading; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.title = @"Plaid Layer Session"; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + + [self setupUI]; + [self createSession]; +} + +- (void)setupUI { + UIScrollView *scrollView = [[UIScrollView alloc] init]; + scrollView.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:scrollView]; + + UIStackView *stackView = [[UIStackView alloc] init]; + stackView.axis = UILayoutConstraintAxisVertical; + stackView.spacing = 16; + stackView.translatesAutoresizingMaskIntoConstraints = NO; + [scrollView addSubview:stackView]; + + self.titleLabel = [[UILabel alloc] init]; + self.titleLabel.text = @"Plaid Layer Session Example"; + self.titleLabel.font = [UIFont boldSystemFontOfSize:20]; + self.titleLabel.textAlignment = NSTextAlignmentCenter; + self.titleLabel.numberOfLines = 0; + [stackView addArrangedSubview:self.titleLabel]; + + self.versionLabel = [[UILabel alloc] init]; + NSString *version = [PLKPlaid sdkVersion]; + self.versionLabel.text = [NSString stringWithFormat:@"LinkKit v%@", version]; + self.versionLabel.font = [UIFont systemFontOfSize:14]; + self.versionLabel.textColor = [UIColor secondaryLabelColor]; + self.versionLabel.textAlignment = NSTextAlignmentCenter; + [stackView addArrangedSubview:self.versionLabel]; + + self.errorLabel = [[UILabel alloc] init]; + self.errorLabel.font = [UIFont systemFontOfSize:14]; + self.errorLabel.textColor = [UIColor systemRedColor]; + self.errorLabel.textAlignment = NSTextAlignmentCenter; + self.errorLabel.numberOfLines = 0; + self.errorLabel.hidden = YES; + [stackView addArrangedSubview:self.errorLabel]; + + UILabel *phoneLabel = [[UILabel alloc] init]; + phoneLabel.text = @"User Phone Number"; + phoneLabel.font = [UIFont systemFontOfSize:14]; + phoneLabel.textColor = [UIColor secondaryLabelColor]; + [stackView addArrangedSubview:phoneLabel]; + + self.phoneNumberTextField = [[UITextField alloc] init]; + self.phoneNumberTextField.placeholder = @"+1 415-555-0011"; + self.phoneNumberTextField.text = @"+1 415-555-0011"; + self.phoneNumberTextField.keyboardType = UIKeyboardTypePhonePad; + self.phoneNumberTextField.borderStyle = UITextBorderStyleRoundedRect; + [stackView addArrangedSubview:self.phoneNumberTextField]; + + UILabel *dobLabel = [[UILabel alloc] init]; + dobLabel.text = @"User Date of Birth"; + dobLabel.font = [UIFont systemFontOfSize:14]; + dobLabel.textColor = [UIColor secondaryLabelColor]; + [stackView addArrangedSubview:dobLabel]; + + self.dobTextField = [[UITextField alloc] init]; + self.dobTextField.placeholder = @"YYYY-MM-DD"; + self.dobTextField.text = @"1975-01-18"; + self.dobTextField.keyboardType = UIKeyboardTypeNumbersAndPunctuation; + self.dobTextField.borderStyle = UITextBorderStyleRoundedRect; + [stackView addArrangedSubview:self.dobTextField]; + + self.submitButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [self.submitButton setTitle:@"Submit User Data" forState:UIControlStateNormal]; + self.submitButton.titleLabel.font = [UIFont boldSystemFontOfSize:16]; + [self.submitButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + self.submitButton.backgroundColor = [UIColor systemBlueColor]; + self.submitButton.layer.cornerRadius = 10; + [self.submitButton addTarget:self action:@selector(submitButtonTapped) forControlEvents:UIControlEventTouchUpInside]; + [stackView addArrangedSubview:self.submitButton]; + + UIView *spacer = [[UIView alloc] init]; + [spacer setContentHuggingPriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisVertical]; + [stackView addArrangedSubview:spacer]; + + self.launchButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [self.launchButton setTitle:@"Launch Layer" forState:UIControlStateNormal]; + self.launchButton.titleLabel.font = [UIFont boldSystemFontOfSize:16]; + [self.launchButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + self.launchButton.backgroundColor = [UIColor systemBlueColor]; + self.launchButton.layer.cornerRadius = 10; + [self.launchButton addTarget:self action:@selector(launchButtonTapped) forControlEvents:UIControlEventTouchUpInside]; + [stackView addArrangedSubview:self.launchButton]; + + self.activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + self.activityIndicator.color = [UIColor whiteColor]; + self.activityIndicator.hidesWhenStopped = YES; + self.activityIndicator.translatesAutoresizingMaskIntoConstraints = NO; + [self.launchButton addSubview:self.activityIndicator]; + + [NSLayoutConstraint activateConstraints:@[ + [scrollView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], + [scrollView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [scrollView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [scrollView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + + [stackView.topAnchor constraintEqualToAnchor:scrollView.topAnchor constant:16], + [stackView.leadingAnchor constraintEqualToAnchor:scrollView.leadingAnchor constant:16], + [stackView.trailingAnchor constraintEqualToAnchor:scrollView.trailingAnchor constant:-16], + [stackView.bottomAnchor constraintEqualToAnchor:scrollView.bottomAnchor constant:-16], + [stackView.widthAnchor constraintEqualToAnchor:scrollView.widthAnchor constant:-32], + + [self.submitButton.heightAnchor constraintEqualToConstant:56], + [self.launchButton.heightAnchor constraintEqualToConstant:56], + [self.activityIndicator.leadingAnchor constraintEqualToAnchor:self.launchButton.leadingAnchor constant:16], + [self.activityIndicator.centerYAnchor constraintEqualToAnchor:self.launchButton.centerYAnchor] + ]]; + + [self updateUI]; +} + +- (void)createSession { + __weak typeof(self) weakSelf = self; + + PLKLayerTokenConfiguration *configuration = [PLKLayerTokenConfiguration createWithToken:self.linkToken onSuccess:^(PLKLinkSuccess *success) { + NSLog(@"public-token: %@ metadata: %@", success.publicToken, success.metadata.metadataJSON); + }]; + + configuration.onExit = ^(PLKLinkExit *exit) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + + if (exit.error) { + NSLog(@"exit with %@\n%@", exit.error, exit.metadata.metadataJSON); + [strongSelf setState:LoadingStateError]; + NSString *displayMessage = exit.error.userInfo[kPLKExitErrorDisplayMessageKey]; + strongSelf.errorMessage = displayMessage ?: exit.error.localizedDescription ?: @"Exited with error"; + [strongSelf updateUI]; + } else { + NSLog(@"exit with %@", exit.metadata.metadataJSON); + } + }; + + configuration.onEvent = ^(PLKLinkEvent *event) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + + NSLog(@"Link Event: %@", event.eventName); + + // Wait for the Layer ready event + if (event.eventName.value == PLKEventNameValueLayerReady) { + [strongSelf setState:LoadingStateReady]; + [strongSelf updateUI]; + } else if (event.eventName.value == PLKEventNameValueLayerNotAvailable) { + [strongSelf setState:LoadingStateError]; + strongSelf.errorMessage = @"Layer not available"; + [strongSelf updateUI]; + } + }; + + NSError *error = nil; + id session = [PLKPlaid createPlaidLayerSessionWithConfiguration:configuration error:&error]; + + if (error) { + self.state = LoadingStateError; + self.errorMessage = error.localizedDescription; + [self updateUI]; + } else { + self.layerSession = session; + } +} + +- (void)submitButtonTapped { + if (self.layerSession) { + PLKSubmissionData *submissionData = [[PLKSubmissionData alloc] init]; + submissionData.phoneNumber = self.phoneNumberTextField.text; + submissionData.dateOfBirth = self.dobTextField.text; + + [self.layerSession submit:submissionData]; + } +} + +- (void)launchButtonTapped { + if (self.layerSession) { + __weak typeof(self) weakSelf = self; + [self.layerSession openWithPresentationHandler:^(UIViewController *viewController) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf presentViewController:viewController animated:YES completion:nil]; + } + } dismissalHandler:^(UIViewController *viewController) { + [viewController dismissViewControllerAnimated:YES completion:nil]; + }]; + } +} + +- (void)updateUI { + dispatch_async(dispatch_get_main_queue(), ^{ + // Submit button is enabled whenever we have a layer session + BOOL hasSession = self.layerSession != nil; + self.submitButton.enabled = hasSession; + self.submitButton.backgroundColor = hasSession ? [UIColor systemBlueColor] : [UIColor systemGrayColor]; + + switch (self.state) { + case LoadingStateLoading: + self.launchButton.enabled = NO; + self.launchButton.backgroundColor = [UIColor systemGrayColor]; + [self.activityIndicator startAnimating]; + self.errorLabel.hidden = YES; + break; + + case LoadingStateReady: + self.launchButton.enabled = YES; + self.launchButton.backgroundColor = [UIColor systemBlueColor]; + [self.activityIndicator stopAnimating]; + self.errorLabel.hidden = YES; + break; + + case LoadingStateError: + self.launchButton.enabled = NO; + self.launchButton.backgroundColor = [UIColor systemGrayColor]; + [self.activityIndicator stopAnimating]; + self.errorLabel.text = self.errorMessage; + self.errorLabel.hidden = NO; + break; + } + }); +} + +@end diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LinkSession/PlaidLinkSessionViewController.h b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LinkSession/PlaidLinkSessionViewController.h new file mode 100644 index 0000000..b11c3cc --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LinkSession/PlaidLinkSessionViewController.h @@ -0,0 +1,18 @@ +// +// PlaidLinkSessionViewController.h +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface PlaidLinkSessionViewController : UIViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken; + +@end + +NS_ASSUME_NONNULL_END diff --git a/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LinkSession/PlaidLinkSessionViewController.m b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LinkSession/PlaidLinkSessionViewController.m new file mode 100644 index 0000000..9718324 --- /dev/null +++ b/LinkDemo-ObjC/LinkDemo-ObjC/ViewControllers/LinkSession/PlaidLinkSessionViewController.m @@ -0,0 +1,209 @@ +// +// PlaidLinkSessionViewController.m +// LinkDemo-ObjC +// +// Copyright © 2026 Plaid Inc. All rights reserved. +// + +#import "PlaidLinkSessionViewController.h" +#import "LinkKitObjectiveC.h" +@import LinkKitObjC; + +typedef NS_ENUM(NSInteger, LoadingState) { + LoadingStateLoading, + LoadingStateReady, + LoadingStateError +}; + +@interface PlaidLinkSessionViewController () + +@property (nonatomic, copy) NSString *linkToken; +@property (nonatomic, assign) LoadingState state; +@property (nonatomic, copy, nullable) NSString *errorMessage; +@property (nonatomic, strong, nullable) id linkSession; + +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *versionLabel; +@property (nonatomic, strong) UILabel *errorLabel; +@property (nonatomic, strong) UIButton *connectButton; +@property (nonatomic, strong) UIActivityIndicatorView *activityIndicator; + +@end + +@implementation PlaidLinkSessionViewController + +- (instancetype)initWithLinkToken:(NSString *)linkToken { + self = [super initWithNibName:nil bundle:nil]; + if (self) { + _linkToken = [linkToken copy]; + _state = LoadingStateLoading; + } + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.title = @"Plaid Link Session"; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + + [self setupUI]; + [self createSession]; +} + +- (void)setupUI { + self.titleLabel = [[UILabel alloc] init]; + self.titleLabel.text = @"Plaid Link Session Example"; + self.titleLabel.font = [UIFont boldSystemFontOfSize:20]; + self.titleLabel.textAlignment = NSTextAlignmentCenter; + self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.titleLabel]; + + self.versionLabel = [[UILabel alloc] init]; + NSString *version = [PLKPlaid sdkVersion]; + self.versionLabel.text = [NSString stringWithFormat:@"LinkKit v%@", version]; + self.versionLabel.font = [UIFont systemFontOfSize:14]; + self.versionLabel.textColor = [UIColor secondaryLabelColor]; + self.versionLabel.textAlignment = NSTextAlignmentCenter; + self.versionLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.versionLabel]; + + self.errorLabel = [[UILabel alloc] init]; + self.errorLabel.font = [UIFont systemFontOfSize:14]; + self.errorLabel.textColor = [UIColor systemRedColor]; + self.errorLabel.textAlignment = NSTextAlignmentCenter; + self.errorLabel.numberOfLines = 0; + self.errorLabel.hidden = YES; + self.errorLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.errorLabel]; + + self.connectButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [self.connectButton setTitle:@"Connect Bank Account" forState:UIControlStateNormal]; + self.connectButton.titleLabel.font = [UIFont boldSystemFontOfSize:16]; + [self.connectButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + self.connectButton.backgroundColor = [UIColor systemBlueColor]; + self.connectButton.layer.cornerRadius = 10; + self.connectButton.translatesAutoresizingMaskIntoConstraints = NO; + [self.connectButton addTarget:self action:@selector(connectButtonTapped) forControlEvents:UIControlEventTouchUpInside]; + [self.view addSubview:self.connectButton]; + + self.activityIndicator = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + self.activityIndicator.color = [UIColor whiteColor]; + self.activityIndicator.hidesWhenStopped = YES; + self.activityIndicator.translatesAutoresizingMaskIntoConstraints = NO; + [self.connectButton addSubview:self.activityIndicator]; + + [NSLayoutConstraint activateConstraints:@[ + [self.titleLabel.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:32], + [self.titleLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:16], + [self.titleLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16], + + [self.versionLabel.topAnchor constraintEqualToAnchor:self.titleLabel.bottomAnchor constant:8], + [self.versionLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:16], + [self.versionLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16], + + [self.errorLabel.topAnchor constraintEqualToAnchor:self.versionLabel.bottomAnchor constant:16], + [self.errorLabel.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:16], + [self.errorLabel.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16], + + [self.connectButton.topAnchor constraintEqualToAnchor:self.errorLabel.bottomAnchor constant:24], + [self.connectButton.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:16], + [self.connectButton.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16], + [self.connectButton.heightAnchor constraintEqualToConstant:56], + + [self.activityIndicator.leadingAnchor constraintEqualToAnchor:self.connectButton.leadingAnchor constant:16], + [self.activityIndicator.centerYAnchor constraintEqualToAnchor:self.connectButton.centerYAnchor] + ]]; + + [self updateUI]; +} + +- (void)createSession { + __weak typeof(self) weakSelf = self; + + PLKLinkTokenConfiguration *configuration = [PLKLinkTokenConfiguration createWithToken:self.linkToken onSuccess:^(PLKLinkSuccess *success) { + NSLog(@"public-token: %@ metadata: %@", success.publicToken, success.metadata.metadataJSON); + }]; + + configuration.onExit = ^(PLKLinkExit *exit) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + + if (exit.error) { + NSLog(@"exit with %@\n%@", exit.error, exit.metadata.metadataJSON); + [strongSelf setState:LoadingStateError]; + NSString *displayMessage = exit.error.userInfo[kPLKExitErrorDisplayMessageKey]; + strongSelf.errorMessage = displayMessage ?: exit.error.localizedDescription ?: @"Exited with error"; + [strongSelf updateUI]; + } else { + NSLog(@"exit with %@", exit.metadata.metadataJSON); + } + }; + + configuration.onEvent = ^(PLKLinkEvent *event) { + NSLog(@"Link Event: %@", event.eventName); + }; + + configuration.onLoad = ^{ + __strong typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return; + + [strongSelf setState:LoadingStateReady]; + [strongSelf updateUI]; + }; + + NSError *error = nil; + id session = [PLKPlaid createPlaidLinkSessionWithConfiguration:configuration error:&error]; + + if (error) { + self.state = LoadingStateError; + self.errorMessage = error.localizedDescription; + [self updateUI]; + } else { + self.linkSession = session; + } +} + +- (void)connectButtonTapped { + if (self.linkSession) { + __weak typeof(self) weakSelf = self; + [self.linkSession openWithPresentationHandler:^(UIViewController *viewController) { + __strong typeof(weakSelf) strongSelf = weakSelf; + if (strongSelf) { + [strongSelf presentViewController:viewController animated:YES completion:nil]; + } + } dismissalHandler:^(UIViewController *viewController) { + [viewController dismissViewControllerAnimated:YES completion:nil]; + }]; + } +} + +- (void)updateUI { + dispatch_async(dispatch_get_main_queue(), ^{ + switch (self.state) { + case LoadingStateLoading: + self.connectButton.enabled = NO; + self.connectButton.backgroundColor = [UIColor systemGrayColor]; + [self.activityIndicator startAnimating]; + self.errorLabel.hidden = YES; + break; + + case LoadingStateReady: + self.connectButton.enabled = YES; + self.connectButton.backgroundColor = [UIColor systemBlueColor]; + [self.activityIndicator stopAnimating]; + self.errorLabel.hidden = YES; + break; + + case LoadingStateError: + self.connectButton.enabled = NO; + self.connectButton.backgroundColor = [UIColor systemGrayColor]; + [self.activityIndicator stopAnimating]; + self.errorLabel.text = self.errorMessage; + self.errorLabel.hidden = NO; + break; + } + }); +} + +@end diff --git a/Package.swift b/Package.swift index b4272d5..f399032 100644 --- a/Package.swift +++ b/Package.swift @@ -12,9 +12,23 @@ let linkKitXCFramework = Target.binaryTarget( let package = Package( name: "LinkKit", platforms: [.iOS(.v15)], - products: [.library(name: "LinkKit", targets: ["LinkKit", "LinkKitSub"])], + products: [ + .library(name: "LinkKit", targets: ["LinkKit", "LinkKitSub"]), + .library(name: "LinkKitObjC", targets: ["LinkKitObjC"]), + ], targets: [ linkKitXCFramework, + .target( + name: "LinkKitObjC", + dependencies: ["LinkKitObjCInternal", "LinkKit"], + path: "Sources/ObjC-Support/Swift" + ), + .target( + name: "LinkKitObjCInternal", + dependencies: ["LinkKit"], + path: "Sources/ObjC-Support/ObjectiveC", + publicHeadersPath: "include" + ), // Without at least one regular (non-binary) target, this package doesn't show up // in Xcode under "Frameworks, Libraries, and Embedded Content". That prevents @@ -22,6 +36,10 @@ let package = Package( // ran on a physical device. As a workaround, we can include a stub target // with at least one source file. // https://github.com/apple/swift-package-manager/issues/6069 - .target(name: "LinkKitSub", path: "Sources", resources: [.copy("Resources/PrivacyInfo.xcprivacy")]), + .target(name: "LinkKitSub", path: "Sources/LinkKitSub", resources: [.copy("Resources/PrivacyInfo.xcprivacy")]), + .testTarget( + name: "LinkKitObjCTests", + dependencies: ["LinkKit", "LinkKitObjC", "LinkKitObjCInternal"] + ), ] ) diff --git a/README.md b/README.md index bedd6cb..0ab57bd 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ Alternatively, you can add the following dependency to your `Package.swift` file .package(url: "https://github.com/plaid/plaid-link-ios-spm.git", from: "6.0.0") ``` +Swift integrations should import the `LinkKit` module. Objective-C integrations +should link the `LinkKitObjC` product and import the `LinkKitObjC` module. + ### Why is there a separate repository for Swift Package Manager support? The main git repository for [plaid-link-ios](https://github.com/plaid/plaid-link-ios) is very large (~1 GB), and Swift Package Manager always downloads the full repository with all git history. This [plaid-link-ios-spm](https://github.com/plaid/plaid-link-ios-spm) repository is much smaller (less than 500kb), so can be downloaded much more quickly. @@ -27,10 +30,10 @@ When using plaid-link-ios-spm, the downloaded `LinkKit.xframework` isn't visible ## Additional Support -📱 The [plaid-link-ios](https://github.com/plaid/plaid-link-ios) repository contains multiple sample applications (requiring Xcode 14) that demonstrate integration and use of Plaid Link for iOS. +📱 Sample applications demonstrate integration and use of Plaid Link for iOS. * [Swift+UIKit](https://github.com/plaid/plaid-link-ios/tree/master/LinkDemo-Swift) * [Swift+SwiftUI](https://github.com/plaid/plaid-link-ios/tree/master/LinkDemo-SwiftUI) -* [Objective-C](https://github.com/plaid/plaid-link-ios/tree/master/LinkDemo-ObjC) +* [Objective-C](LinkDemo-ObjC) 📚 Detailed instructions on how to integrate with Plaid Link for iOS can be found in our main documentation at [plaid.com/docs/link/ios][link-ios-docs]. diff --git a/Sources/LinkKitStub.swift b/Sources/LinkKitSub/LinkKitStub.swift similarity index 100% rename from Sources/LinkKitStub.swift rename to Sources/LinkKitSub/LinkKitStub.swift diff --git a/Sources/Resources/PrivacyInfo.xcprivacy b/Sources/LinkKitSub/Resources/PrivacyInfo.xcprivacy similarity index 100% rename from Sources/Resources/PrivacyInfo.xcprivacy rename to Sources/LinkKitSub/Resources/PrivacyInfo.xcprivacy diff --git a/Sources/ObjC-Support/ObjectiveC/PLKPlaid.m b/Sources/ObjC-Support/ObjectiveC/PLKPlaid.m new file mode 100644 index 0000000..26141f6 --- /dev/null +++ b/Sources/ObjC-Support/ObjectiveC/PLKPlaid.m @@ -0,0 +1,738 @@ +#import "PLKPlaid.h" +#import "PLKPlaid+PrivateInitializers.h" + +#pragma mark - Internal + +@implementation PLKAccount + +- (instancetype)initWithAccountID:(NSString *)ID + name:(NSString *)name + mask:(NSString *)mask + subtype:(id)subtype + verificationStatus:(PLKVerificationStatus *)verificationStatus { + if (self = [super init]) { + _ID = [ID copy]; + _name = [name copy]; + _mask = [mask copy]; + _subtype = subtype; + _verificationStatus = verificationStatus; + } + return self; +} + +@end + +@interface PLKVerificationStatus () + +@property(nonatomic, readwrite, nullable, copy) NSString *unknownStringValue; +@property(nonatomic, readwrite) PLKVerificationStatusValue value; + +@end + +@implementation PLKVerificationStatus + ++ (instancetype)createWithValue:(PLKVerificationStatusValue)value { + PLKVerificationStatus *status = [[self alloc] init]; + + status.value = value; + + return status; +} + ++ (instancetype)createWithUnknownStringValue:(NSString *)unknownStringValue { + PLKVerificationStatus *status = [[self alloc] init]; + + status.value = PLKVerificationStatusValueNone; + status.unknownStringValue = unknownStringValue; + + return status; +} + +@end + +@interface PLKEventName () + +@property(nonatomic, readwrite, nullable, copy) NSString *unknownStringValue; +@property(nonatomic, readwrite) PLKEventNameValue value; + +@end + +@implementation PLKEventName + ++ (instancetype)createWithValue:(PLKEventNameValue)value { + PLKEventName *status = [[self alloc] init]; + + status.value = value; + + return status; +} + ++ (instancetype)createWithUnknownStringValue:(NSString *)unknownStringValue { + PLKEventName *status = [[self alloc] init]; + + status.value = PLKEventNameValueNone; + status.unknownStringValue = unknownStringValue; + + return status; +} + +@end + +@interface PLKViewName () + +@property(nonatomic, readwrite, nullable, copy) NSString *unknownStringValue; +@property(nonatomic, readwrite) PLKViewNameValue value; + +@end + +@implementation PLKViewName + ++ (instancetype)createWithValue:(PLKViewNameValue)value { + PLKViewName *status = [[self alloc] init]; + + status.value = value; + + return status; +} + ++ (instancetype)createWithUnknownStringValue:(NSString *)unknownStringValue { + PLKViewName *status = [[self alloc] init]; + + status.value = PLKViewNameValueNone; + status.unknownStringValue = unknownStringValue; + + return status; +} + +@end + +@interface PLKExitStatus () + +@property(nonatomic, readwrite, nullable, copy) NSString *unknownStringValue; +@property(nonatomic, readwrite) PLKExitStatusValue value; + +@end + +@implementation PLKExitStatus + ++ (instancetype)createWithValue:(PLKExitStatusValue)value { + PLKExitStatus *status = [[self alloc] init]; + + status.value = value; + + return status; +} + ++ (instancetype)createWithUnknownStringValue:(NSString *)unknownStringValue { + PLKExitStatus *status = [[self alloc] init]; + + status.value = PLKExitStatusValueNone; + status.unknownStringValue = unknownStringValue; + + return status; +} + +@end + +@interface PLKAccountSubtypeUnknown () + +@property(nonatomic, readwrite, copy) NSString *rawStringValue; +@property(nonatomic, readwrite, copy) NSString *rawSubtypeStringValue; + +@end + +@implementation PLKAccountSubtypeUnknown + ++ (instancetype)createWithRawTypeStringValue:(NSString *)rawTypeStringValue + rawSubtypeStringValue:(NSString *)rawSubtypeStringValue { + PLKAccountSubtypeUnknown *unknownSubtype = [[self alloc] init]; + unknownSubtype.rawStringValue = rawTypeStringValue; + unknownSubtype.rawSubtypeStringValue = rawSubtypeStringValue; + return unknownSubtype; +} + +@end + +@interface PLKAccountSubtypeOther () + +@property(nonatomic, readwrite, copy) NSString *rawStringValue; + +@end + +@implementation PLKAccountSubtypeOther + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueOther)value { + PLKAccountSubtypeOther *subtype = [[self alloc] init]; + + switch (value) { + case PLKAccountSubtypeValueOtherAll: + subtype.rawStringValue = @"all"; + break; + case PLKAccountSubtypeValueOtherNone: + subtype.rawStringValue = nil; + break; + case PLKAccountSubtypeValueOtherOther: + subtype.rawStringValue = @"other"; + break; + } + + return subtype; +} + ++ (instancetype)createWithRawStringValue:(NSString *)rawStringValue { + PLKAccountSubtypeOther *otherSubtype = [[self alloc] init]; + otherSubtype.rawStringValue = rawStringValue; + return otherSubtype; +} + +@end + +@interface PLKAccountSubtypeCredit () + +@property(nonatomic, readwrite, copy) NSString *rawStringValue; + +@end + +@implementation PLKAccountSubtypeCredit + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueCredit)value { + PLKAccountSubtypeCredit *subtype = [[self alloc] init]; + + switch (value) { + case PLKAccountSubtypeValueCreditAll: + subtype.rawStringValue = @"all"; + break; + case PLKAccountSubtypeValueCreditNone: + subtype.rawStringValue = nil; + break; + case PLKAccountSubtypeValueCreditCreditCard: + subtype.rawStringValue = @"credit card"; + break; + case PLKAccountSubtypeValueCreditPaypal: + subtype.rawStringValue = @"paypal"; + break; + } + + return subtype; +} + ++ (instancetype)createWithUnknownValue:(NSString *)unknownValue { + PLKAccountSubtypeCredit *subtype = [[self alloc] init]; + subtype.rawStringValue = unknownValue; + return subtype; +} + +@end + +@interface PLKAccountSubtypeLoan () + +@property(nonatomic, readwrite, copy) NSString *rawStringValue; + +@end + +@implementation PLKAccountSubtypeLoan + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueLoan)value { + PLKAccountSubtypeLoan *subtype = [[self alloc] init]; + + switch (value) { + case PLKAccountSubtypeValueLoanAll: + subtype.rawStringValue = @"all"; + break; + case PLKAccountSubtypeValueLoanNone: + subtype.rawStringValue = nil; + break; + case PLKAccountSubtypeValueLoanAuto: + subtype.rawStringValue = @"auto"; + break; + case PLKAccountSubtypeValueLoanBusiness: + subtype.rawStringValue = @"business"; + break; + case PLKAccountSubtypeValueLoanCommercial: + subtype.rawStringValue = @"commercial"; + break; + case PLKAccountSubtypeValueLoanConstruction: + subtype.rawStringValue = @"construction"; + break; + case PLKAccountSubtypeValueLoanConsumer: + subtype.rawStringValue = @"consumer"; + break; + case PLKAccountSubtypeValueLoanHomeEquity: + subtype.rawStringValue = @"home equity"; + break; + case PLKAccountSubtypeValueLoanLineOfCredit: + subtype.rawStringValue = @"line of credit"; + break; + case PLKAccountSubtypeValueLoanLoan: + subtype.rawStringValue = @"loan"; + break; + case PLKAccountSubtypeValueLoanMortgage: + subtype.rawStringValue = @"mortgage"; + break; + case PLKAccountSubtypeValueLoanOverdraft: + subtype.rawStringValue = @"overdraft"; + break; + case PLKAccountSubtypeValueLoanStudent: + subtype.rawStringValue = @"student"; + break; + } + + return subtype; +} + ++ (instancetype)createWithUnknownValue:(NSString *)unknownValue { + PLKAccountSubtypeLoan *subtype = [[self alloc] init]; + subtype.rawStringValue = unknownValue; + return subtype; +} + +@end + +@interface PLKAccountSubtypeDepository () + +@property(nonatomic, readwrite, copy) NSString *rawStringValue; + +@end + +@implementation PLKAccountSubtypeDepository + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueDepository)value { + PLKAccountSubtypeDepository *subtype = [[self alloc] init]; + + switch (value) { + case PLKAccountSubtypeValueDepositoryAll: + subtype.rawStringValue = @"all"; + break; + case PLKAccountSubtypeValueDepositoryNone: + subtype.rawStringValue = nil; + break; + case PLKAccountSubtypeValueDepositoryCashManagement: + subtype.rawStringValue = @"cash management"; + break; + case PLKAccountSubtypeValueDepositoryCd: + subtype.rawStringValue = @"cd"; + break; + case PLKAccountSubtypeValueDepositoryChecking: + subtype.rawStringValue = @"checking"; + break; + case PLKAccountSubtypeValueDepositoryEbt: + subtype.rawStringValue = @"ebt"; + break; + case PLKAccountSubtypeValueDepositoryHsa: + subtype.rawStringValue = @"hsa"; + break; + case PLKAccountSubtypeValueDepositoryMoneyMarket: + subtype.rawStringValue = @"money market"; + break; + case PLKAccountSubtypeValueDepositoryPaypal: + subtype.rawStringValue = @"paypal"; + break; + case PLKAccountSubtypeValueDepositoryPrepaid: + subtype.rawStringValue = @"prepaid"; + break; + case PLKAccountSubtypeValueDepositorySavings: + subtype.rawStringValue = @"savings"; + break; + } + + return subtype; +} + ++ (instancetype)createWithUnknownValue:(NSString *)unknownValue { + PLKAccountSubtypeDepository *subtype = [[self alloc] init]; + subtype.rawStringValue = unknownValue; + return subtype; +} + +@end + +@interface PLKAccountSubtypeInvestment () + +@property(nonatomic, readwrite, copy) NSString *rawStringValue; + +@end + +@implementation PLKAccountSubtypeInvestment + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueInvestment)value { + PLKAccountSubtypeInvestment *subtype = [[self alloc] init]; + + switch (value) { + case PLKAccountSubtypeValueInvestmentAll: + subtype.rawStringValue = @"all"; + break; + case PLKAccountSubtypeValueInvestmentNone: + subtype.rawStringValue = nil; + break; + case PLKAccountSubtypeValueInvestment401a: + subtype.rawStringValue = @"401a"; + break; + case PLKAccountSubtypeValueInvestment401k: + subtype.rawStringValue = @"401k"; + break; + case PLKAccountSubtypeValueInvestment403B: + subtype.rawStringValue = @"403B"; + break; + case PLKAccountSubtypeValueInvestment457b: + subtype.rawStringValue = @"457b"; + break; + case PLKAccountSubtypeValueInvestment529: + subtype.rawStringValue = @"529"; + break; + case PLKAccountSubtypeValueInvestmentBrokerage: + subtype.rawStringValue = @"brokerage"; + break; + case PLKAccountSubtypeValueInvestmentCashIsa: + subtype.rawStringValue = @"cash isa"; + break; + case PLKAccountSubtypeValueInvestmentEducationSavingsAccount: + subtype.rawStringValue = @"education savings account"; + break; + case PLKAccountSubtypeValueInvestmentFixedAnnuity: + subtype.rawStringValue = @"fixed annuity"; + break; + case PLKAccountSubtypeValueInvestmentGic: + subtype.rawStringValue = @"gic"; + break; + case PLKAccountSubtypeValueInvestmentHealthReimbursementArrangement: + subtype.rawStringValue = @"health reimbursement arrangement"; + break; + case PLKAccountSubtypeValueInvestmentHsa: + subtype.rawStringValue = @"hsa"; + break; + case PLKAccountSubtypeValueInvestmentIra: + subtype.rawStringValue = @"ira"; + break; + case PLKAccountSubtypeValueInvestmentIsa: + subtype.rawStringValue = @"isa"; + break; + case PLKAccountSubtypeValueInvestmentKeogh: + subtype.rawStringValue = @"keogh"; + break; + case PLKAccountSubtypeValueInvestmentLif: + subtype.rawStringValue = @"lif"; + break; + case PLKAccountSubtypeValueInvestmentLira: + subtype.rawStringValue = @"lira"; + break; + case PLKAccountSubtypeValueInvestmentLrif: + subtype.rawStringValue = @"lrif"; + break; + case PLKAccountSubtypeValueInvestmentLrsp: + subtype.rawStringValue = @"lrsp"; + break; + case PLKAccountSubtypeValueInvestmentMutualFund: + subtype.rawStringValue = @"mutual fund"; + break; + case PLKAccountSubtypeValueInvestmentNonTaxableBrokerageAccount: + subtype.rawStringValue = @"non-taxable brokerage account"; + break; + case PLKAccountSubtypeValueInvestmentPension: + subtype.rawStringValue = @"pension"; + break; + case PLKAccountSubtypeValueInvestmentPlan: + subtype.rawStringValue = @"plan"; + break; + case PLKAccountSubtypeValueInvestmentPrif: + subtype.rawStringValue = @"prif"; + break; + case PLKAccountSubtypeValueInvestmentProfitSharingPlan: + subtype.rawStringValue = @"profit sharing plan"; + break; + case PLKAccountSubtypeValueInvestmentRdsp: + subtype.rawStringValue = @"rdsp"; + break; + case PLKAccountSubtypeValueInvestmentResp: + subtype.rawStringValue = @"resp"; + break; + case PLKAccountSubtypeValueInvestmentRetirement: + subtype.rawStringValue = @"retirement"; + break; + case PLKAccountSubtypeValueInvestmentRlif: + subtype.rawStringValue = @"rlif"; + break; + case PLKAccountSubtypeValueInvestmentRoth401k: + subtype.rawStringValue = @"roth 401k"; + break; + case PLKAccountSubtypeValueInvestmentRoth: + subtype.rawStringValue = @"roth"; + break; + case PLKAccountSubtypeValueInvestmentRrif: + subtype.rawStringValue = @"rrif"; + break; + case PLKAccountSubtypeValueInvestmentRrsp: + subtype.rawStringValue = @"rrsp"; + break; + case PLKAccountSubtypeValueInvestmentSarsep: + subtype.rawStringValue = @"sarsep"; + break; + case PLKAccountSubtypeValueInvestmentSepIra: + subtype.rawStringValue = @"sep ira"; + break; + case PLKAccountSubtypeValueInvestmentSimpleIra: + subtype.rawStringValue = @"simple ira"; + break; + case PLKAccountSubtypeValueInvestmentSipp: + subtype.rawStringValue = @"sipp"; + break; + case PLKAccountSubtypeValueInvestmentStockPlan: + subtype.rawStringValue = @"stock plan"; + break; + case PLKAccountSubtypeValueInvestmentTfsa: + subtype.rawStringValue = @"tfsa"; + break; + case PLKAccountSubtypeValueInvestmentThriftSavingsPlan: + subtype.rawStringValue = @"thrift savings plan"; + break; + case PLKAccountSubtypeValueInvestmentTrust: + subtype.rawStringValue = @"trust"; + break; + case PLKAccountSubtypeValueInvestmentUgma: + subtype.rawStringValue = @"ugma"; + break; + case PLKAccountSubtypeValueInvestmentUtma: + subtype.rawStringValue = @"utma"; + break; + case PLKAccountSubtypeValueInvestmentVariableAnnuity: + subtype.rawStringValue = @"variable annuity"; + break; + } + + return subtype; +} + ++ (instancetype)createWithUnknownValue:(NSString *)unknownValue { + PLKAccountSubtypeInvestment *subtype = [[self alloc] init]; + subtype.rawStringValue = unknownValue; + return subtype; +} + +@end + +@implementation PLKInstitution + +- (instancetype)initWithID:(NSString *)ID name:(NSString *)name { + if (self = [super init]) { + _name = [name copy]; + _ID = [ID copy]; + } + return self; +} + +@end + +@implementation PLKEventMetadata + +- (instancetype)initWithError:(PLKExitError *)error + exitStatus:(PLKExitStatus *)exitStatus + institutionID:(NSString *)institutionID + institutionName:(NSString *)institutionName + institutionSearchQuery:(NSString *)institutionSearchQuery + accountNumberMask:(NSString *)accountNumberMask + isUpdateMode:(NSString *)isUpdateMode + matchReason:(NSString *)matchReason + issueID:(NSString *)issueID + issueDescription:(NSString *)issueDescription + issueDetectedAt:(NSDate *)issueDetectedAt + routingNumber:(NSString *)routingNumber + selection:(NSString *)selection + linkSessionID:(NSString *)linkSessionID + mfaType:(PLKMFAType)mfaType + requestID:(NSString *)requestID + timestamp:(NSDate *)timestamp + viewName:(PLKViewName *)viewName + metadataJSON:(PLKRawJSONMetadata *)metadataJSON { + if (self = [super init]) { + _error = error; + _exitStatus = exitStatus; + _institutionID = [institutionID copy]; + _institutionName = [institutionName copy]; + _institutionSearchQuery = [institutionSearchQuery copy]; + _accountNumberMask = [accountNumberMask copy]; + _isUpdateMode = [isUpdateMode copy]; + _matchReason = [matchReason copy]; + _issueID = [issueID copy]; + _issueDescription = [issueDescription copy]; + _issueDetectedAt = [issueDetectedAt copy]; + _routingNumber = [routingNumber copy]; + _selection = [selection copy]; + _linkSessionID = [linkSessionID copy]; + _mfaType = mfaType; + _requestID = requestID; + _timestamp = timestamp; + _viewName = viewName; + _metadataJSON = metadataJSON; + } + return self; +} + +@end + +@implementation PLKLinkEvent + +- (instancetype)initWithEventName:(PLKEventName *)eventName + eventMetadata:(PLKEventMetadata *)eventMetadata { + if (self = [super init]) { + _eventName = eventName; + _eventMetadata = eventMetadata; + } + return self; +} + +@end + +@implementation PLKExitMetadata + +- (instancetype)initWithStatus:(PLKExitStatus *)status + institution:(PLKInstitution *)institution + requestID:(NSString *)requestID + linkSessionID:(NSString *)linkSessionID + metadataJSON:(PLKRawJSONMetadata *)metadataJSON { + if (self = [super init]) { + _status = status; + _institution = institution; + _requestID = [requestID copy]; + _linkSessionID = [linkSessionID copy]; + _metadataJSON = [metadataJSON copy]; + } + return self; +} + +@end + +@implementation PLKLinkExit + +- (instancetype)initWithError:(NSError *)error + metadata:(PLKExitMetadata *)metadata { + if (self = [super init]) { + _error = error; + _metadata = metadata; + } + return self; +} + +@end + +@implementation PLKSuccessMetadata + +- (instancetype)initWithLinkSessionID:(NSString *)linkSessionID + institution:(PLKInstitution *)institution + accounts:(NSArray *)accounts + metadataJSON:(PLKRawJSONMetadata *)metadataJSON { + if (self = [super init]) { + _linkSessionID = [linkSessionID copy]; + _institution = institution; + _accounts = [accounts copy]; + _metadataJSON = [metadataJSON copy]; + } + return self; +} + +@end + +@implementation PLKLinkSuccess + +- (instancetype)initWithPublicToken:(NSString *)publicToken + metadata:(PLKSuccessMetadata *)metadata { + if (self = [super init]) { + _publicToken = [publicToken copy]; + _metadata = metadata; + } + return self; +} + +@end + +#pragma mark - Public + +@implementation PLKLinkTokenConfiguration + +- (instancetype)initWithToken:(NSString *)token + onSuccess:(PLKOnSuccessHandler)successHandler { + NSParameterAssert(token); + NSParameterAssert(successHandler); + + if (self = [super init]) { + _token = [token copy]; + _onSuccess = successHandler; + + // Initializing handlers + _onExit = ^(PLKLinkExit *exit){ }; + _onEvent = ^(PLKLinkEvent *event){ }; + + // Since onLoad is nullable, we can start as nil, + // or provide an empty block if the SDK expects to call it blindly. + _onLoad = nil; + } + return self; +} + ++ (instancetype)createWithToken:(NSString *)token + onSuccess:(PLKOnSuccessHandler)successHandler { + return [[self alloc] initWithToken:token + onSuccess:successHandler]; +} + +@end + +@implementation PLKLayerTokenConfiguration + +- (instancetype)initWithToken:(NSString *)token + onSuccess:(PLKOnSuccessHandler)successHandler { + NSParameterAssert(token); + NSParameterAssert(successHandler); + + if (self = [super init]) { + _token = [token copy]; + _onSuccess = successHandler; + // Default empty blocks to prevent crashes if called before being set + _onExit = ^(PLKLinkExit *exit){ }; + _onEvent = ^(PLKLinkEvent *event){ }; + } + return self; +} + ++ (instancetype)createWithToken:(NSString *)token + onSuccess:(PLKOnSuccessHandler)successHandler { + return [[self alloc] initWithToken:token + onSuccess:successHandler]; +} + +@end + +@implementation PLKEmbeddedLinkTokenConfiguration + +- (instancetype)initWithToken:(NSString *)token + onSuccess:(PLKOnSuccessHandler)successHandler { + NSParameterAssert(token); + NSParameterAssert(successHandler); + + if (self = [super init]) { + _token = [token copy]; + _onSuccess = successHandler; + // Default empty blocks to prevent crashes if called before being set + _onExit = ^(PLKLinkExit *exit){ }; + _onEvent = ^(PLKLinkEvent *event){ }; + } + return self; +} + ++ (instancetype)createWithToken:(NSString *)token + onSuccess:(PLKOnSuccessHandler)successHandler { + return [[self alloc] initWithToken:token + onSuccess:successHandler]; +} + +@end + +@implementation PLKSubmissionData + +- (instancetype)initWithPhoneNumber:(NSString * _Nullable)phoneNumber { + self = [super init]; + if (self) { + _phoneNumber = [phoneNumber copy]; + } + return self; +} + +@end diff --git a/Sources/ObjC-Support/ObjectiveC/include/LinkKitObjectiveC.h b/Sources/ObjC-Support/ObjectiveC/include/LinkKitObjectiveC.h new file mode 100644 index 0000000..d24094f --- /dev/null +++ b/Sources/ObjC-Support/ObjectiveC/include/LinkKitObjectiveC.h @@ -0,0 +1,3 @@ +#import +#import "PLKPlaid.h" +#import "PLKPlaid+PrivateInitializers.h" diff --git a/Sources/ObjC-Support/ObjectiveC/include/PLKPlaid+PrivateInitializers.h b/Sources/ObjC-Support/ObjectiveC/include/PLKPlaid+PrivateInitializers.h new file mode 100644 index 0000000..31339f6 --- /dev/null +++ b/Sources/ObjC-Support/ObjectiveC/include/PLKPlaid+PrivateInitializers.h @@ -0,0 +1,122 @@ +#ifndef PLKPlaid_PrivateInitializers_h +#define PLKPlaid_PrivateInitializers_h + +/// NOTE: The initializers exposed through this header are meant for use only by internal LinkKit code and are +/// subject to change without notice, or a corresponding semantic version bump. If there is a use case that requires +/// the use of these initializers, please let Plaid know so a path forward can be found using the public, stable API. +NS_ASSUME_NONNULL_BEGIN + +@interface PLKVerificationStatus (PrivateInitializers) + ++ (instancetype)createWithValue:(PLKVerificationStatusValue)value; ++ (instancetype)createWithUnknownStringValue:(NSString *)unknownStringValue; + +@end + +@interface PLKEventName (PrivateInitializers) + ++ (instancetype)createWithValue:(PLKEventNameValue)value; ++ (instancetype)createWithUnknownStringValue:(NSString *)unknownStringValue; + +@end + +@interface PLKViewName (PrivateInitializers) + ++ (instancetype)createWithValue:(PLKViewNameValue)value; ++ (instancetype)createWithUnknownStringValue:(NSString *)unknownStringValue; + +@end + +@interface PLKExitStatus (PrivateInitializers) + ++ (instancetype)createWithValue:(PLKExitStatusValue)value; ++ (instancetype)createWithUnknownStringValue:(NSString *)unknownStringValue; + +@end + +@interface PLKAccount (PrivateInitializers) + +- (instancetype)initWithAccountID:(NSString *)ID + name:(NSString *)name + mask:(NSString * __nullable)mask + subtype:(id)subtype + verificationStatus:(PLKVerificationStatus * __nullable)verificationStatus; + +@end + +@interface PLKInstitution (PrivateInitializers) + +- (instancetype)initWithID:(NSString *)ID + name:(NSString *)name; + +@end + +@interface PLKEventMetadata (PrivateInitializers) + +- (instancetype)initWithError:(PLKExitError * __nullable)error + exitStatus:(PLKExitStatus * __nullable)exitStatus + institutionID:(NSString * __nullable)institutionID + institutionName:(NSString * __nullable)institutionName + institutionSearchQuery:(NSString * __nullable)institutionSearchQuery + accountNumberMask:(NSString * __nullable)accountNumberMask + isUpdateMode:(NSString * __nullable)isUpdateMode + matchReason:(NSString * __nullable)matchReason + issueID:(NSString * __nullable)issueID + issueDescription:(NSString * __nullable)issueDescription + issueDetectedAt:(NSDate * __nullable)issueDetectedAt + routingNumber:(NSString * __nullable)routingNumber + selection:(NSString * __nullable)selection + linkSessionID:(NSString *)linkSessionID + mfaType:(PLKMFAType)mfaType + requestID:(NSString * __nullable)requestID + timestamp:(NSDate *)timestamp + viewName:(PLKViewName * __nullable)viewName + metadataJSON:(PLKRawJSONMetadata * __nullable)metadataJSON; + +@end + +@interface PLKLinkEvent (PrivateInitializers) + +- (instancetype)initWithEventName:(PLKEventName *)eventName + eventMetadata:(PLKEventMetadata *)eventMetadata; + +@end + +@interface PLKExitMetadata (PrivateInitializers) + +- (instancetype)initWithStatus:(PLKExitStatus * __nullable)status + institution:(PLKInstitution * __nullable)institution + requestID:(NSString * __nullable)requestID + linkSessionID:(NSString * __nullable)linkSessionID + metadataJSON:(PLKRawJSONMetadata * __nullable)metadataJSON; + +@end + +@interface PLKLinkExit (PrivateInitializers) + +- (instancetype)initWithError:(NSError * __nullable)error + metadata:(PLKExitMetadata *)metadata; + +@end + +@interface PLKLinkSuccess (PrivateInitializers) + +- (instancetype)initWithPublicToken:(NSString *)publicToken + metadata:(PLKSuccessMetadata *)metadata; + +@end + +@interface PLKSuccessMetadata (PrivateInitializers) + +- (instancetype)initWithLinkSessionID:(NSString *)linkSessionID + institution:(PLKInstitution *)institution + accounts:(NSArray *)accounts + metadataJSON:(PLKRawJSONMetadata * __nullable)metadataJSON; + +@end + +NS_ASSUME_NONNULL_END + + +#endif // PLKPlaid_PrivateInitializers_h + diff --git a/Sources/ObjC-Support/ObjectiveC/include/PLKPlaid.h b/Sources/ObjC-Support/ObjectiveC/include/PLKPlaid.h new file mode 100644 index 0000000..009a8cf --- /dev/null +++ b/Sources/ObjC-Support/ObjectiveC/include/PLKPlaid.h @@ -0,0 +1,673 @@ +#import + +/// PLKPlaid.h exposes an Objective-C API to interact with LinkKit. The Swift API is the preferred means of integration, so +/// prefer to use the Swift API where possible. + +typedef NSString PLKRawJSONMetadata; +typedef NSString PLKLanguage; +typedef NSString PLKCountryCode; + +/// An NSError instance containing metadata about the error. The domain will be a kPLKExitErrorDomain value +/// and the code will be a case in the enum associated with that domain. For example, an API error will have +/// the domain value be kPLKExitErrorApiDomain and the code will be a case in the PLKApiErrorCode enum. +/// NOTE: To ensure forwards compatability, if the server returns an error in a domain that's not yet +/// supported by LinkKit it will have a domain of PLKExitErrorDomainUnknownError, a code of `-1`, and +/// an NSString * value in its userInfo dictionary for the kPLKExitErrorUnknownDomainCodeKey key. +typedef NSError PLKExitError; + +typedef NS_ENUM(NSInteger, PLKProduct) { + PLKProductAssets, + PLKProductAuth, + PLKProductDepositSwitch, + PLKProductIdentity, + PLKProductIncome, + PLKProductInvestments, + PLKProductLiabilities, + PLKProductLiabilitiesReport, + PLKProductPaymentInitiation, + PLKProductTransactions, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKVerificationStatusValue) { + PLKVerificationStatusValueNone = -1, + PLKVerificationStatusValuePendingAutomaticVerification, + PLKVerificationStatusValuePendingManualVerification, + PLKVerificationStatusValueManuallyVerified, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKEventNameValue) { + PLKEventNameValueNone = -1, + PLKEventNameValueCloseOAuth, + PLKEventNameValueError, + PLKEventNameValueExit, + PLKEventNameValueFailOAuth, + PLKEventNameValueHandoff, + PLKEventNameValueOpen, + PLKEventNameValueOpenMyPlaid, + PLKEventNameValueOpenOAuth, + PLKEventNameValueProfileEligibilityCheckReady, + PLKEventNameValueProfileEligibilityCheckError, + PLKEventNameValueSearchInstitution, + PLKEventNameValueSelectInstitution, + PLKEventNameValueSubmitCredentials, + PLKEventNameValueSubmitMFA, + PLKEventNameValueTransitionView, + PLKEventNameValueMatchedSelectInstitution, + PLKEventNameValueMatchedSelectVerifyMethod, + PLKEventNameValueBankIncomeInsightsCompleted, + PLKEventNameValueSelectDegradedInstitution, + PLKEventNameValueSelectDownInstitution, + PLKEventNameValueIdentityVerificationStartStep, + PLKEventNameValueIdentityVerificationPassStep, + PLKEventNameValueIdentityVerificationFailStep, + PLKEventNameValueIdentityVerificationPendingReviewStep, + PLKEventNameValueIdentityVerificationPendingReviewSession, + PLKEventNameValueIdentityVerificationCreateSession, + PLKEventNameValueIdentityVerificationResumeSession, + PLKEventNameValueIdentityVerificationPassSession, + PLKEventNameValueIdentityVerificationFailSession, + PLKEventNameValueIdentityVerificationOpenUI, + PLKEventNameValueIdentityVerificationResumeUI, + PLKEventNameValueIdentityVerificationCloseUI, + PLKEventNameValueSelectFilteredInstitution, + PLKEventNameValueSelectBrand, + PLKEventNameValueSelectAuthType, + PLKEventNameValueSubmitAccountNumber, + PLKEventNameValueSubmitDocuments, + PLKEventNameValueSubmitDocumentsSuccess, + PLKEventNameValueSubmitDocumentsError, + PLKEventNameValueSubmitOTP, + PLKEventNameValueSubmitRoutingNumber, + PLKEventNameValueViewDataTypes, + PLKEventNameValueSubmitPhone, + PLKEventNameValueSkipSubmitPhone, + PLKEventNameValueVerifyPhone, + PLKEventNameValueConnectNewInstitution, + PLKEventNameValueLayerReady, + PLKEventNameValueLayerNotAvailable, + PLKEventNameValueSubmitEmail, + PLKEventNameValueSkipSubmitEmail, + PLKEventNameValueRememberMeEnabled, + PLKEventNameValueRememberMeDisabled, + PLKEventNameValueRememberMeHoldout, + PLKEventNameValueSelectSavedInstitution, + PLKEventNameValueSelectSavedAccount, + PLKEventNameValueAutoSelectSavedInstitution, + PLKEventNameValuePlaidCheckPane, + PLKEventNameValueAutoSubmitPhone, + PLKEventNameValueIdentityMatchPassed, + PLKEventNameValueIdentityMatchFailed, + PLKEventNameValueIssueFollowed, + PLKEventNameValueSelectAccount, + PLKEventNameValueLayerAutoFillNotAvailable, + PLKEventNameValueSelectFallbackRoutingInstitution, + PLKEventNameValueSelectDenylistedInstitution, + PLKEventNameValueSelectRememberMeDuplicateInstitution, + // Add new enum cases directly above this line to avoid breaking API changes +}; + + +typedef NS_ENUM(NSInteger, PLKExitStatusValue) { + PLKExitStatusValueNone = -1, + PLKExitStatusValueRequiresQuestions, + PLKExitStatusValueRequiresSelections, + PLKExitStatusValueRequiresCode, + PLKExitStatusValueChooseDevice, + PLKExitStatusValueRequiresCredentials, + PLKExitStatusValueInstitutionNotFound, + PLKExitStatusValueRequiresAccountSelection, + PLKExitStatusValueContinueToThridParty + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKMFAType) { + PLKMFATypeNone = -1, + PLKMFATypeCode, + PLKMFATypeDevice, + PLKMFATypeQuestions, + PLKMFATypeSelections, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKViewNameValue) { + PLKViewNameValueNone = -1, + PLKViewNameValueConnected, + PLKViewNameValueConsent, + PLKViewNameValueCredential, + PLKViewNameValueError, + PLKViewNameValueExit, + PLKViewNameValueLoading, + PLKViewNameValueMFA, + PLKViewNameValueNumbers, + PLKViewNameValueRecaptcha, + PLKViewNameValueSelectAccount, + PLKViewNameValueSelectInstitution, + PLKViewNameValueMatchedConsent, + PLKViewNameValueMatchedCredential, + PLKViewNameValueMatchedMFA, + PLKViewNameValueUploadDocuments, + PLKViewNameValueSubmitDocuments, + PLKViewNameValueSubmitDocumentsSuccess, + PLKViewNameValueSubmitDocumentsError, + PLKViewNameValueProfileDataReview, + PLKViewNameValueOauth, + PLKViewNameValueAcceptTOS, + PLKViewNameValueDocumentaryVerification, + PLKViewNameValueKYCCheck, + PLKViewNameValueSelfieCheck, + PLKViewNameValueRiskCheck, + PLKViewNameValueScreening, + PLKViewNameValueVerifySMS, + PLKViewNameValueDataTransparency, + PLKViewNameValueDataTransparencyConsent, + PLKViewNameValueSelectAuthType, + PLKViewNameValueSelectBrand, + PLKViewNameValueNumbersSelectInstitution, + PLKViewNameValueSubmitPhone, + PLKViewNameValueVerifyPhone, + PLKViewNameValueSelectSavedInstitution, + PLKViewNameValueSelectSavedAccount, + PLKViewNameValueSubmitEmail, + PLKViewNameValueVerifyEmail, + PLKViewNameValueDataTransparencyMessagingModal, + PLKViewNameValueIdentityMatchBlock, + PLKViewNameValueCRAConsent, + PLKViewNameValueInstantMicrodepositAuthorized, + PLKViewNameValueSameDayMicrodepositAuthorized, + PLKViewNameValueInstantMicrodepositVerification, + PLKViewNameValueSameDayMicrodepositVerification, + PLKViewNameValueTransferStatusCheck, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKAccountSubtypeValueOther) { + PLKAccountSubtypeValueOtherNone = -1, + PLKAccountSubtypeValueOtherAll, + PLKAccountSubtypeValueOtherOther, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKAccountSubtypeValueCredit) { + PLKAccountSubtypeValueCreditNone = -1, + PLKAccountSubtypeValueCreditAll, + PLKAccountSubtypeValueCreditCreditCard, + PLKAccountSubtypeValueCreditPaypal, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKAccountSubtypeValueLoan) { + PLKAccountSubtypeValueLoanNone = -1, + PLKAccountSubtypeValueLoanAll, + PLKAccountSubtypeValueLoanAuto, + PLKAccountSubtypeValueLoanBusiness, + PLKAccountSubtypeValueLoanCommercial, + PLKAccountSubtypeValueLoanConstruction, + PLKAccountSubtypeValueLoanConsumer, + PLKAccountSubtypeValueLoanHomeEquity, + PLKAccountSubtypeValueLoanLineOfCredit, + PLKAccountSubtypeValueLoanLoan, + PLKAccountSubtypeValueLoanMortgage, + PLKAccountSubtypeValueLoanOverdraft, + PLKAccountSubtypeValueLoanStudent, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKAccountSubtypeValueDepository) { + PLKAccountSubtypeValueDepositoryNone = -1, + PLKAccountSubtypeValueDepositoryAll, + PLKAccountSubtypeValueDepositoryCashManagement, + PLKAccountSubtypeValueDepositoryCd, + PLKAccountSubtypeValueDepositoryChecking, + PLKAccountSubtypeValueDepositoryEbt, + PLKAccountSubtypeValueDepositoryHsa, + PLKAccountSubtypeValueDepositoryMoneyMarket, + PLKAccountSubtypeValueDepositoryPaypal, + PLKAccountSubtypeValueDepositoryPrepaid, + PLKAccountSubtypeValueDepositorySavings, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKAccountSubtypeValueInvestment) { + PLKAccountSubtypeValueInvestmentNone = -1, + PLKAccountSubtypeValueInvestmentAll, + PLKAccountSubtypeValueInvestment401a, + PLKAccountSubtypeValueInvestment401k, + PLKAccountSubtypeValueInvestment403B, + PLKAccountSubtypeValueInvestment457b, + PLKAccountSubtypeValueInvestment529, + PLKAccountSubtypeValueInvestmentBrokerage, + PLKAccountSubtypeValueInvestmentCashIsa, + PLKAccountSubtypeValueInvestmentEducationSavingsAccount, + PLKAccountSubtypeValueInvestmentFixedAnnuity, + PLKAccountSubtypeValueInvestmentGic, + PLKAccountSubtypeValueInvestmentHealthReimbursementArrangement, + PLKAccountSubtypeValueInvestmentHsa, + PLKAccountSubtypeValueInvestmentIra, + PLKAccountSubtypeValueInvestmentIsa, + PLKAccountSubtypeValueInvestmentKeogh, + PLKAccountSubtypeValueInvestmentLif, + PLKAccountSubtypeValueInvestmentLira, + PLKAccountSubtypeValueInvestmentLrif, + PLKAccountSubtypeValueInvestmentLrsp, + PLKAccountSubtypeValueInvestmentMutualFund, + PLKAccountSubtypeValueInvestmentNonTaxableBrokerageAccount, + PLKAccountSubtypeValueInvestmentPension, + PLKAccountSubtypeValueInvestmentPlan, + PLKAccountSubtypeValueInvestmentPrif, + PLKAccountSubtypeValueInvestmentProfitSharingPlan, + PLKAccountSubtypeValueInvestmentRdsp, + PLKAccountSubtypeValueInvestmentResp, + PLKAccountSubtypeValueInvestmentRetirement, + PLKAccountSubtypeValueInvestmentRlif, + PLKAccountSubtypeValueInvestmentRoth401k, + PLKAccountSubtypeValueInvestmentRoth, + PLKAccountSubtypeValueInvestmentRrif, + PLKAccountSubtypeValueInvestmentRrsp, + PLKAccountSubtypeValueInvestmentSarsep, + PLKAccountSubtypeValueInvestmentSepIra, + PLKAccountSubtypeValueInvestmentSimpleIra, + PLKAccountSubtypeValueInvestmentSipp, + PLKAccountSubtypeValueInvestmentStockPlan, + PLKAccountSubtypeValueInvestmentTfsa, + PLKAccountSubtypeValueInvestmentThriftSavingsPlan, + PLKAccountSubtypeValueInvestmentTrust, + PLKAccountSubtypeValueInvestmentUgma, + PLKAccountSubtypeValueInvestmentUtma, + PLKAccountSubtypeValueInvestmentVariableAnnuity, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKApiErrorCode) { + PLKApiErrorCodeInternalServerError, + PLKApiErrorCodePlannedMaintenance + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKInvalidRequestErrorCode) { + PLKInvalidRequestErrorCodeMissingFields, + PLKInvalidRequestErrorCodeUnknownFields, + PLKInvalidRequestErrorCodeInvalidField, + PLKInvalidRequestErrorCodeInvalidBody, + PLKInvalidRequestErrorCodeInvalidAddress, + PLKInvalidRequestErrorCodeNotFound, + PLKInvalidRequestErrorCodeSandboxOnly, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKInvalidInputErrorCode) { + PLKInvalidInputErrorCodeInvalidApiKeys, + PLKInvalidInputErrorCodeUnauthorizedEnvironment, + PLKInvalidInputErrorCodeInvalidAccessToken, + PLKInvalidInputErrorCodeInvalidPublicToken, + PLKInvalidInputErrorCodeInvalidProduct, + PLKInvalidInputErrorCodeInvalidAccountId, + PLKInvalidInputErrorCodeInvalidInstitution, + PLKInvalidInputErrorCodeTooManyVerificationAttempts, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKInstitutionErrorCode) { + PLKInstitutionErrorCodeInstitutionDown, + PLKInstitutionErrorCodeInstitutionNotResponding, + PLKInstitutionErrorCodeInstitutionNotAvailable, + PLKInstitutionErrorCodeInstitutionNoLongerSupported, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKRateLimitErrorCode) { + PLKRateLimitErrorCodeAccountsLimit, + PLKRateLimitErrorCodeAdditionLimit, + PLKRateLimitErrorCodeAuthLimit, + PLKRateLimitErrorCodeIdentityLimit, + PLKRateLimitErrorCodeIncomeLimit, + PLKRateLimitErrorCodeItemGetLimit, + PLKRateLimitErrorCodeRateLimit, + PLKRateLimitErrorCodeTransactionsLimit, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKItemErrorCode) { + PLKItemErrorCodeInsufficientCredentials, + PLKItemErrorCodeInvalidCredentials, + PLKItemErrorCodeInvalidMfa, + PLKItemErrorCodeInvalidSendMethod, + PLKItemErrorCodeInvalidUpdatedUsername, + PLKItemErrorCodeItemLocked, + PLKItemErrorCodeItemLoginRequired, + PLKItemErrorCodeItemNoError, + PLKItemErrorCodeItemNotSupported, + PLKItemErrorCodeIncorrectDepositAmounts, + PLKItemErrorCodeUserSetupRequired, + PLKItemErrorCodeMfaNotSupported, + PLKItemErrorCodeNoAccounts, + PLKItemErrorCodeNoAuthAccounts, + PLKItemErrorCodeNoInvestmentAccounts, + PLKItemErrorCodeNoLiabilityAccounts, + PLKItemErrorCodeProductNotReady, + PLKItemErrorCodeProductsNotSupported, + PLKItemErrorCodeInstantMatchFailed, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKAuthErrorCode) { + PLKAuthErrorCodeProductNotReady, + PLKAuthErrorCodeVerificationExpired, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +typedef NS_ENUM(NSInteger, PLKAssetReportErrorCode) { + PLKAssetReportErrorCodeProductNotEnabled, + PLKAssetReportErrorCodeDataUnavailable, + PLKAssetReportErrorCodeProductNotReady, + PLKAssetReportErrorCodeAssetReportGenerationFailed, + PLKAssetReportErrorCodeInvalidParent, + PLKAssetReportErrorCodeInsightsNotEnabled, + PLKAssetReportErrorCodeInsightsPreviouslyNotEnabled, + // Add new enum cases directly above this line to avoid breaking API changes +}; + +NS_ASSUME_NONNULL_BEGIN + +static NSString *const kPLKExitErrorMessageKey = @"PLKExitErrorMessageKey"; +static NSString *const kPLKExitErrorDisplayMessageKey = @"PLKExitErrorDisplayMessageKey"; +static NSString *const kPLKExitErrorRawJSONKey = @"PLKExitErrorRawJSONKey"; +static NSString *const kPLKExitErrorUnknownTypeKey = @"kPLKExitErrorUnknownTypeKey"; +static NSString *const kPLKExitErrorCodeKey = @"kPLKExitErrorCodeKey"; + +static NSString *const kPLKExitErrorInvalidRequestDomain = @"PLKExitErrorInvalidRequestDomain"; +static NSString *const kPLKExitErrorInvalidInputDomain = @"PLKExitErrorInvalidInputDomain"; +static NSString *const kPLKExitErrorInstitutionErrorDomain = @"PLKExitErrorInstitutionErrorDomain"; +static NSString *const kPLKExitErrorRateLimitExceededDomain = @"PLKExitErrorRateLimitExceededDomain"; +static NSString *const kPLKExitErrorApiDomain = @"PLKExitErrorApiDomain"; +static NSString *const kPLKExitErrorItemDomain = @"PLKExitErrorItemDomain"; +static NSString *const kPLKExitErrorAuthDomain = @"PLKExitErrorAuthDomain"; +static NSString *const kPLKExitErrorAssetReportDomain = @"PLKExitErrorAssetReportDomain"; +static NSString *const kPLKExitErrorInternalDomain = @"PLKExitErrorInternalDomain"; +static NSString *const kPLKExitErrorUnknownDomain = @"PLKExitErrorUnknownDomain"; +static NSString *const kPLKDefaultErrorDomain = @"com.plaid.link"; + +@interface PLKVerificationStatus : NSObject + +/// If the server returned a verification status value the SDK is not aware of, unknownStringValue will be non-nil +@property(nonatomic, readonly, nullable, copy) NSString *unknownStringValue; +@property(nonatomic, readonly) PLKVerificationStatusValue value; + +@end + +@interface PLKEventName : NSObject + +/// If the server returned an event name value the SDK is not aware of, unknownStringValue will be non-nil +@property(nonatomic, readonly, nullable, copy) NSString *unknownStringValue; +@property(nonatomic, readonly) PLKEventNameValue value; + +@end + +@interface PLKViewName : NSObject + +/// If the server returned an event name value the SDK is not aware of, unknownStringValue will be non-nil +@property(nonatomic, readonly, nullable, copy) NSString *unknownStringValue; +@property(nonatomic, readonly) PLKViewNameValue value; + +@end + +@interface PLKExitStatus : NSObject + +/// If the server returned an event name value the SDK is not aware of, unknownStringValue will be non-nil +@property(nonatomic, readonly, nullable, copy) NSString *unknownStringValue; +@property(nonatomic, readonly) PLKExitStatusValue value; + +@end + +@protocol PLKAccountSubtype + +@property(nonatomic, readonly, nullable, copy) NSString *rawStringValue; + +@end + +@interface PLKAccountSubtypeUnknown : NSObject + +@property(nonatomic, readonly, nullable, copy) NSString *rawSubtypeStringValue; + ++ (instancetype)createWithRawTypeStringValue:(NSString *)rawTypeStringValue + rawSubtypeStringValue:(NSString *)rawTypeSubStringValue; + +@end + +@interface PLKAccountSubtypeOther : NSObject + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueOther)value; ++ (instancetype)createWithRawStringValue:(NSString *)rawStringValue; + +@end + +@interface PLKAccountSubtypeCredit : NSObject + +@property(nonatomic, readonly) PLKAccountSubtypeValueCredit subtype; + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueCredit)value; ++ (instancetype)createWithUnknownValue:(NSString *)unknownValue; + +@end + +@interface PLKAccountSubtypeLoan : NSObject + +@property(nonatomic, readonly) PLKAccountSubtypeValueLoan subtype; + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueLoan)value; ++ (instancetype)createWithUnknownValue:(NSString *)unknownValue; + +@end + +@interface PLKAccountSubtypeDepository : NSObject + +@property(nonatomic, readonly) PLKAccountSubtypeValueDepository subtype; + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueDepository)value; ++ (instancetype)createWithUnknownValue:(NSString *)unknownValue; + +@end + +@interface PLKAccountSubtypeInvestment : NSObject + +@property(nonatomic, readonly) PLKAccountSubtypeValueInvestment subtype; + ++ (instancetype)createWithValue:(PLKAccountSubtypeValueInvestment)value; ++ (instancetype)createWithUnknownValue:(NSString *)unknownValue; + +@end + +@interface PLKInstitution : NSObject + +@property(nonatomic, readonly, copy) NSString *name; +@property(nonatomic, readonly, copy) NSString *ID; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface PLKAccount : NSObject + +@property(nonatomic, readonly, copy) NSString *ID; +@property(nonatomic, readonly, copy) NSString *name; +@property(nonatomic, readonly, nullable, copy) NSString *mask; +@property(nonatomic, readonly) id subtype; +@property(nonatomic, readonly, nullable) PLKVerificationStatus *verificationStatus; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface PLKSuccessMetadata : NSObject + +@property(nonatomic, readonly, copy) NSString *linkSessionID; +@property(nonatomic, readonly) PLKInstitution *institution; +@property(nonatomic, readonly, copy) NSArray *accounts; +@property(nonatomic, readonly, nullable, copy) PLKRawJSONMetadata *metadataJSON; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface PLKLinkSuccess : NSObject + +@property(nonatomic, readonly, copy) NSString *publicToken; +@property(nonatomic, readonly) PLKSuccessMetadata *metadata; + +- (instancetype)initWithPublicToken:(NSString *)publicToken + metadata:(PLKSuccessMetadata *)metadata NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface PLKExitMetadata : NSObject + +@property(nonatomic, readonly, nullable) PLKExitStatus *status; +@property(nonatomic, readonly, nullable) PLKInstitution *institution; +@property(nonatomic, readonly, copy) NSString *requestID; +@property(nonatomic, readonly, copy) NSString *linkSessionID; +@property(nonatomic, readonly, nullable) PLKRawJSONMetadata *metadataJSON; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface PLKLinkExit : NSObject + +@property(nonatomic, readonly, nullable) NSError *error; +@property(nonatomic, readonly) PLKExitMetadata *metadata; + +- (instancetype)initWithError:(NSError * __nullable)error + metadata:(PLKExitMetadata *)metadata NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface PLKSubmissionData : NSObject +@property (nonatomic, copy, nullable) NSString *phoneNumber; +@property (nonatomic, copy, nullable) NSString *dateOfBirth; +@property (nonatomic, copy, nullable) NSDictionary *params; +@end + +@interface PLKEventMetadata : NSObject + +@property(nonatomic, readonly, nullable) PLKExitError *error; +@property(nonatomic, readonly, nullable) PLKExitStatus *exitStatus; +@property(nonatomic, readonly, nullable, copy) NSString *institutionID; +@property(nonatomic, readonly, nullable, copy) NSString *institutionName; +@property(nonatomic, readonly, nullable, copy) NSString *institutionSearchQuery; +@property(nonatomic, readonly, nullable, copy) NSString *accountNumberMask; +@property(nonatomic, readonly, nullable, copy) NSString *isUpdateMode; +@property(nonatomic, readonly, nullable, copy) NSString *matchReason; +@property(nonatomic, readonly, nullable, copy) NSString *issueID; +@property(nonatomic, readonly, nullable, copy) NSString *issueDescription; +@property(nonatomic, readonly, nullable, copy) NSDate *issueDetectedAt; +@property(nonatomic, readonly, nullable, copy) NSString *routingNumber; +@property(nonatomic, readonly, nullable, copy) NSString *selection; +@property(nonatomic, readonly, copy) NSString *linkSessionID; +@property(nonatomic, readonly) PLKMFAType mfaType; +@property(nonatomic, readonly, nullable, copy) NSString *requestID; +@property(nonatomic, readonly) NSDate *timestamp; +@property(nonatomic, readonly, nullable) PLKViewName *viewName; +@property(nonatomic, readonly) PLKRawJSONMetadata *metadataJSON; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +@interface PLKLinkEvent : NSObject + +@property(nonatomic, readonly) PLKEventName *eventName; +@property(nonatomic, readonly) PLKEventMetadata *eventMetadata; + +- (instancetype)init NS_UNAVAILABLE; + +@end + +typedef void(^PLKOnSuccessHandler)(PLKLinkSuccess *); +typedef void(^PLKOnExitHandler)(PLKLinkExit *); +typedef void(^PLKOnEventHandler)(PLKLinkEvent *); + +/// A block called when Plaid Link has finished loading. +typedef void(^PLKOnLoadHandler)(void); + +@interface PLKLinkTokenConfiguration : NSObject + +@property(nonatomic, copy, readonly) NSString *token; + +@property(nonatomic) PLKOnSuccessHandler onSuccess; +@property(nonatomic, strong) PLKOnExitHandler onExit; +@property(nonatomic, strong) PLKOnEventHandler onEvent; +@property (nonatomic, copy, nullable) PLKOnLoadHandler onLoad; + +- (instancetype)initWithToken:(NSString *)token onSuccess:(PLKOnSuccessHandler)successHandler NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)createWithToken:(NSString *)token onSuccess:(PLKOnSuccessHandler)successHandler; + +@end + +@interface PLKLayerTokenConfiguration : NSObject + +@property(nonatomic, copy, readonly) NSString *token; + +@property(nonatomic, copy) PLKOnSuccessHandler onSuccess; +@property(nonatomic, strong) PLKOnExitHandler onExit; +@property(nonatomic, strong) PLKOnEventHandler onEvent; + +- (instancetype)initWithToken:(NSString *)token onSuccess:(PLKOnSuccessHandler)successHandler NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)createWithToken:(NSString *)token onSuccess:(PLKOnSuccessHandler)successHandler; + +@end + +@interface PLKEmbeddedLinkTokenConfiguration : NSObject + +@property(nonatomic, copy, readonly) NSString *token; + +@property(nonatomic, copy) PLKOnSuccessHandler onSuccess; +@property(nonatomic, strong) PLKOnExitHandler onExit; +@property(nonatomic, strong) PLKOnEventHandler onEvent; + +- (instancetype)initWithToken:(NSString *)token onSuccess:(PLKOnSuccessHandler)successHandler NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)createWithToken:(NSString *)token onSuccess:(PLKOnSuccessHandler)successHandler; + +@end + +typedef NS_ENUM(NSInteger, PLKEnvironment) { + PLKEnvironmentProduction, + PLKEnvironmentSandbox, +}; + +/// Both `PLKPresentationHandler` and `PLKDismissalHandler` clsoures take the Plaid Link View Controller to be +/// presented or dismissed as a parameter. +typedef void(^PLKPresentationHandler)(UIViewController *); +typedef void(^PLKDismissalHandler)(UIViewController *); + +@protocol PLKPlaidLinkSession + +/// Controls whether the gradient background is visible. +@property (nonatomic, assign) BOOL showGradientBackground; + +- (void)openWithPresentationHandler:(PLKPresentationHandler)presentationHandler + dismissalHandler:(PLKDismissalHandler)dismissalHandler; + +@end + +@protocol PLKPlaidLayerSession + +- (void) submit:(PLKSubmissionData *)data; + +- (void)openWithPresentationHandler:(PLKPresentationHandler)presentationHandler + dismissalHandler:(PLKDismissalHandler)dismissalHandler; + +@end + +@protocol PLKPlaidHeadlessSession + +- (void) start; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/ObjC-Support/Swift/Models/PLKAccount.swift b/Sources/ObjC-Support/Swift/Models/PLKAccount.swift new file mode 100644 index 0000000..fc22ed8 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKAccount.swift @@ -0,0 +1,15 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension Account { + var toObjC: PLKAccount { + return PLKAccount( + accountID: id, + name: name, + mask: mask, + subtype: subtype.toObjC, + verificationStatus: verificationStatus?.toObjC + ) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKAccountSubtype.swift b/Sources/ObjC-Support/Swift/Models/PLKAccountSubtype.swift new file mode 100644 index 0000000..b3a5416 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKAccountSubtype.swift @@ -0,0 +1,241 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension AccountSubtype.Credit { + var toObjC: PLKAccountSubtypeValueCredit? { + switch self { + case .all: + return .all + case .creditCard: + return .creditCard + case .paypal: + return .paypal + case .unknown: + return nil + @unknown default: + return nil + } + } +} + +extension AccountSubtype.Loan { + var toObjC: PLKAccountSubtypeValueLoan? { + switch self { + case .all: + return .all + case .unknown: + return nil + case .auto: + return .auto + case .business: + return .business + case .commercial: + return .commercial + case .construction: + return .construction + case .consumer: + return .consumer + case .homeEquity: + return .homeEquity + case .lineOfCredit: + return .lineOfCredit + case .loan: + return .loan + case .mortgage: + return .mortgage + case .overdraft: + return .overdraft + case .student: + return .student + @unknown default: + return nil + } + } +} + +extension AccountSubtype.Depository { + var toObjC: PLKAccountSubtypeValueDepository? { + switch self { + case .all: + return .all + case .unknown: + return nil + case .cashManagement: + return .cashManagement + case .cd: + return .cd + case .checking: + return .checking + case .ebt: + return .ebt + case .hsa: + return .hsa + case .moneyMarket: + return .moneyMarket + case .paypal: + return .paypal + case .prepaid: + return .prepaid + case .savings: + return .savings + @unknown default: + return nil + } + } +} + +extension AccountSubtype.Investment { + var toObjC: PLKAccountSubtypeValueInvestment? { + switch self { + case .all: + return .investmentAll + case .unknown: + return nil + case .investment401a: + return .investment401a + case .investment401k: + return .investment401k + case .investment403B: + return .investment403B + case .investment457b: + return .investment457b + case .investment529: + return .investment529 + case .brokerage: + return .investmentBrokerage + case .cashIsa: + return .investmentCashIsa + case .educationSavingsAccount: + return .investmentEducationSavingsAccount + case .fixedAnnuity: + return .investmentFixedAnnuity + case .gic: + return .investmentGic + case .healthReimbursementArrangement: + return .investmentHealthReimbursementArrangement + case .hsa: + return .investmentHsa + case .ira: + return .investmentIra + case .isa: + return .investmentIsa + case .keogh: + return .investmentKeogh + case .lif: + return .investmentLif + case .lira: + return .investmentLira + case .lrif: + return .investmentLrif + case .lrsp: + return .investmentLrsp + case .mutualFund: + return .investmentMutualFund + case .nonTaxableBrokerageAccount: + return .investmentNonTaxableBrokerageAccount + case .pension: + return .investmentPension + case .plan: + return .investmentPlan + case .prif: + return .investmentPrif + case .profitSharingPlan: + return .investmentProfitSharingPlan + case .rdsp: + return .investmentRdsp + case .resp: + return .investmentResp + case .retirement: + return .investmentRetirement + case .rlif: + return .investmentRlif + case .roth401k: + return .investmentRoth401k + case .roth: + return .investmentRoth + case .rrif: + return .investmentRrif + case .rrsp: + return .investmentRrsp + case .sarsep: + return .investmentSarsep + case .sepIra: + return .investmentSepIra + case .simpleIra: + return .investmentSimpleIra + case .sipp: + return .investmentSipp + case .stockPlan: + return .investmentStockPlan + case .tfsa: + return .investmentTfsa + case .thriftSavingsPlan: + return .investmentThriftSavingsPlan + case .trust: + return .investmentTrust + case .ugma: + return .investmentUgma + case .utma: + return .investmentUtma + case .variableAnnuity: + return .investmentVariableAnnuity + @unknown default: + return nil + } + } +} + +extension AccountSubtype { + var toObjC: PLKAccountSubtype { + switch self { + case .unknown(let type, let subtype): + return PLKAccountSubtypeUnknown.create(withRawTypeStringValue: type, rawSubtypeStringValue: subtype) + + case .other(let other): + switch other { + case .all: + return PLKAccountSubtypeOther.create(withValue: .all) + case .other: + return PLKAccountSubtypeOther.create(withValue: .other) + case .unknown(let rawValue): + return PLKAccountSubtypeOther.create(withRawStringValue: rawValue) + @unknown default: + return PLKAccountSubtypeOther.create(withRawStringValue: "unknown default") + } + + case .credit(let credit): + if let creditValue = credit.toObjC { + return PLKAccountSubtypeCredit.create(withValue: creditValue) + } else { + return PLKAccountSubtypeCredit.create(withUnknownValue: credit.description) + } + + case .loan(let loan): + if let loanValue = loan.toObjC { + return PLKAccountSubtypeLoan.create(withValue: loanValue) + } else { + return PLKAccountSubtypeLoan.create(withUnknownValue: loan.description) + } + + case .depository(let depository): + if let depositoryValue = depository.toObjC { + return PLKAccountSubtypeDepository.create(withValue: depositoryValue) + } else { + return PLKAccountSubtypeDepository.create(withUnknownValue: depository.description) + } + + case .investment(let investment): + if let investmentValue = investment.toObjC { + return PLKAccountSubtypeInvestment.create(withValue: investmentValue) + } else { + return PLKAccountSubtypeInvestment.create(withUnknownValue: investment.description) + } + @unknown default: + return PLKAccountSubtypeUnknown.create( + withRawTypeStringValue: "unknownDefault", + rawSubtypeStringValue: "unknownDefault" + ) + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKApiErrorCode.swift b/Sources/ObjC-Support/Swift/Models/PLKApiErrorCode.swift new file mode 100644 index 0000000..f6bbaeb --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKApiErrorCode.swift @@ -0,0 +1,18 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension ApiErrorCode { + var toObjC: PLKApiErrorCode? { + switch self { + case .internalServerError: + return .internalServerError + case .plannedMaintenance: + return .plannedMaintenance + case .unknown: + return nil + @unknown default: + return nil + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKAssetReportErrorCode.swift b/Sources/ObjC-Support/Swift/Models/PLKAssetReportErrorCode.swift new file mode 100644 index 0000000..1a2fe79 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKAssetReportErrorCode.swift @@ -0,0 +1,28 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension AssetReportErrorCode { + var toObjC: PLKAssetReportErrorCode? { + switch self { + case .productNotEnabled: + return .productNotEnabled + case .dataUnavailable: + return .dataUnavailable + case .productNotReady: + return .productNotReady + case .assetReportGenerationFailed: + return .assetReportGenerationFailed + case .invalidParent: + return .invalidParent + case .insightsNotEnabled: + return .insightsNotEnabled + case .insightsPreviouslyNotEnabled: + return .insightsPreviouslyNotEnabled + case .unknown: + return nil + @unknown default: + return nil + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKAuthErrorCode.swift b/Sources/ObjC-Support/Swift/Models/PLKAuthErrorCode.swift new file mode 100644 index 0000000..4e60652 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKAuthErrorCode.swift @@ -0,0 +1,18 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension AuthErrorCode { + var toObjC: PLKAuthErrorCode? { + switch self { + case .productNotReady: + return .productNotReady + case .verificationExpired: + return .verificationExpired + case .unknown: + return nil + @unknown default: + return nil + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKEmbeddedLinkTokenConfiguration.swift b/Sources/ObjC-Support/Swift/Models/PLKEmbeddedLinkTokenConfiguration.swift new file mode 100644 index 0000000..840cb18 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKEmbeddedLinkTokenConfiguration.swift @@ -0,0 +1,27 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension PLKEmbeddedLinkTokenConfiguration { + var toSwift: EmbeddedLinkTokenConfiguration { + let onSuccess: OnSuccessHandler = { success in + self.onSuccess(success.toObjC) + } + + let onExit: OnExitHandler = { exit in + self.onExit(exit.toObjC) + } + + let onEvent: OnEventHandler = { event in + self.onEvent(event.toObjC) + } + + let result = EmbeddedLinkTokenConfiguration( + token: token, + onSuccess: onSuccess, + onExit: onExit, + onEvent: onEvent + ) + return result + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKEnvironment.swift b/Sources/ObjC-Support/Swift/Models/PLKEnvironment.swift new file mode 100644 index 0000000..ddbef9f --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKEnvironment.swift @@ -0,0 +1,16 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension PLKEnvironment { + var toSwift: Environment? { + switch self { + case .production: + return .production + case .sandbox: + return .sandbox + @unknown default: + return nil + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKEventMetadata.swift b/Sources/ObjC-Support/Swift/Models/PLKEventMetadata.swift new file mode 100644 index 0000000..f2ee6f5 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKEventMetadata.swift @@ -0,0 +1,38 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension EventMetadata { + var toObjC: PLKEventMetadata { + let error: ExitError? = errorCode.map { + ExitError.privateObjCInitializer( + errorCode: $0, + errorMessage: errorMessage ?? "", + displayMesssage: nil, + errorJSON: nil + ) + } + + return PLKEventMetadata( + error: error?.toObjC, + exitStatus: exitStatus?.toObjC, + institutionID: institutionID, + institutionName: institutionName, + institutionSearchQuery: institutionSearchQuery, + accountNumberMask: accountNumberMask, + isUpdateMode: isUpdateMode, + matchReason: matchReason, + issueID: issueID, + issueDescription: issueDescription, + issueDetectedAt: issueDetectedAt, + routingNumber: routingNumber, + selection: selection, + linkSessionID: linkSessionID, + mfaType: mfaType?.toObjC ?? .none, + requestID: requestID, + timestamp: timestamp, + viewName: viewName?.toObjC, + metadataJSON: metadataJSON + ) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKEventName.swift b/Sources/ObjC-Support/Swift/Models/PLKEventName.swift new file mode 100644 index 0000000..bb894e3 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKEventName.swift @@ -0,0 +1,154 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension EventName { + private var toObjCValue: PLKEventNameValue? { + switch self { + case .autoSubmitPhone: + return .autoSubmitPhone + case .bankIncomeInsightsCompleted: + return .bankIncomeInsightsCompleted + case .closeOAuth: + return .closeOAuth + case .error: + return .error + case .exit: + return .exit + case .failOAuth: + return .failOAuth + case .handoff: + return .handoff + case .identityVerificationStartStep: + return .identityVerificationStartStep + case .identityVerificationPassStep: + return .identityVerificationPassStep + case .identityVerificationFailStep: + return .identityVerificationFailStep + case .identityVerificationPendingReviewStep: + return .identityVerificationPendingReviewStep + case .identityVerificationPendingReviewSession: + return .identityVerificationPendingReviewSession + case .identityVerificationCreateSession: + return .identityVerificationCreateSession + case .identityVerificationResumeSession: + return .identityVerificationResumeSession + case .identityVerificationPassSession: + return .identityVerificationPassSession + case .identityVerificationFailSession: + return .identityVerificationFailSession + case .identityVerificationOpenUI: + return .identityVerificationOpenUI + case .identityVerificationResumeUI: + return .identityVerificationResumeUI + case .identityVerificationCloseUI: + return .identityVerificationCloseUI + case .layerAutoFillNotAvailable: + return .layerAutoFillNotAvailable + case .layerReady: + return .layerReady + case .layerNotAvailable: + return .layerNotAvailable + case .matchedSelectInstitution: + return .matchedSelectInstitution + case .matchedSelectVerifyMethod: + return .matchedSelectVerifyMethod + case .open: + return .open + case .openMyPlaid: + return .openMyPlaid + case .openOAuth: + return .openOAuth + case .profileEligibilityCheckReady: + return .profileEligibilityCheckReady + case .profileEligibilityCheckError: + return .profileEligibilityCheckError + case .searchInstitution: + return .searchInstitution + case .selectDegradedInstitution: + return .selectInstitution + case .selectDownInstitution: + return .selectInstitution + case .selectFallbackRoutingInstitution: + return .selectFallbackRoutingInstitution + case .selectDenylistedInstitution: + return .selectDenylistedInstitution + case .selectRememberMeDuplicateInstitution: + return .selectRememberMeDuplicateInstitution + case .selectInstitution: + return .selectInstitution + case .submitCredentials: + return .submitCredentials + case .submitMFA: + return .submitMFA + case .transitionView: + return .transitionView + case .selectFilteredInstitution: + return .selectFilteredInstitution + case .selectBrand: + return .selectBrand + case .selectAuthType: + return .selectAuthType + case .submitAccountNumber: + return .submitAccountNumber + case .submitDocuments: + return .submitDocuments + case .submitDocumentsSuccess: + return .submitDocumentsSuccess + case .submitDocumentsError: + return .submitDocumentsError + case .submitRoutingNumber: + return .submitRoutingNumber + case .viewDataTypes: + return .viewDataTypes + case .submitPhone: + return .submitPhone + case .skipSubmitPhone: + return .skipSubmitPhone + case .submitOTP: + return .submitOTP + case .verifyPhone: + return .verifyPhone + case .connectNewInstitution: + return .connectNewInstitution + case .submitEmail: + return .submitEmail + case .skipSubmitEmail: + return .skipSubmitEmail + case .rememberMeEnabled: + return .rememberMeEnabled + case .rememberMeDisabled: + return .rememberMeDisabled + case .rememberMeHoldout: + return .rememberMeHoldout + case .selectSavedInstitution: + return .selectSavedInstitution + case .selectSavedAccount: + return .selectSavedAccount + case .autoSelectSavedInstitution: + return .autoSelectSavedInstitution + case .plaidCheckPane: + return .plaidCheckPane + case .unknown: + return nil + case .identityMatchPassed: + return .identityMatchPassed + case .identityMatchFailed: + return .identityMatchFailed + case .issueFollowed: + return .issueFollowed + case .selectAccount: + return .selectAccount + @unknown default: + return nil + } + } + + var toObjC: PLKEventName { + guard let value = self.toObjCValue else { + return .create(withUnknownStringValue: description) + } + + return .create(with: value) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKExitError.swift b/Sources/ObjC-Support/Swift/Models/PLKExitError.swift new file mode 100644 index 0000000..de6a3d0 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKExitError.swift @@ -0,0 +1,75 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension ExitError { + var toObjC: NSError { + var userInfo: [String: String] = [ + kPLKExitErrorMessageKey: errorMessage, + kPLKExitErrorDisplayMessageKey: displayMessage ?? "", + NSLocalizedDescriptionKey: displayMessage ?? "", + kPLKExitErrorRawJSONKey: errorJSON ?? "", + ] + + let errorDomain: String + let errorCodeInt: Int? + let errorCodeString: String? + switch errorCode { + + case .apiError(let code): + errorDomain = kPLKExitErrorApiDomain + errorCodeInt = code.toObjC?.rawValue + errorCodeString = code.description + case .authError(let code): + errorDomain = kPLKExitErrorAuthDomain + errorCodeInt = code.toObjC?.rawValue + errorCodeString = code.description + case .assetReportError(let code): + errorDomain = kPLKExitErrorAssetReportDomain + errorCodeInt = code.toObjC?.rawValue + errorCodeString = code.description + case .internal(let code): + errorDomain = kPLKExitErrorInternalDomain + errorCodeInt = nil + errorCodeString = code + case .institutionError(let code): + errorDomain = kPLKExitErrorInstitutionErrorDomain + errorCodeInt = code.toObjC?.rawValue + errorCodeString = code.description + case .itemError(let code): + errorDomain = kPLKExitErrorItemDomain + errorCodeInt = code.toObjC?.rawValue + errorCodeString = code.description + case .invalidInput(let code): + errorDomain = kPLKExitErrorInvalidInputDomain + errorCodeInt = code.toObjC?.rawValue + errorCodeString = code.description + case .invalidRequest(let code): + errorDomain = kPLKExitErrorInvalidRequestDomain + errorCodeInt = code.toObjC?.rawValue + errorCodeString = code.description + case .rateLimitExceeded(let code): + errorDomain = kPLKExitErrorRateLimitExceededDomain + errorCodeInt = code.toObjC?.rawValue + errorCodeString = code.description + case .unknown(let type, let code): + errorDomain = kPLKExitErrorUnknownDomain + userInfo[kPLKExitErrorUnknownTypeKey] = type + errorCodeInt = nil + errorCodeString = code + @unknown default: + errorDomain = kPLKExitErrorUnknownDomain + userInfo[kPLKExitErrorUnknownTypeKey] = "unknown default" + errorCodeInt = 5555 + errorCodeString = "5555" + } + + userInfo[kPLKExitErrorCodeKey] = errorCodeString + + return NSError( + domain: errorDomain, + code: errorCodeInt ?? -1, + userInfo: userInfo + ) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKExitMetadata.swift b/Sources/ObjC-Support/Swift/Models/PLKExitMetadata.swift new file mode 100644 index 0000000..17df077 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKExitMetadata.swift @@ -0,0 +1,15 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension ExitMetadata { + var toObjC: PLKExitMetadata { + return PLKExitMetadata( + status: status?.toObjC, + institution: institution?.toObjC, + requestID: requestID, + linkSessionID: linkSessionID, + metadataJSON: metadataJSON + ) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKExitStatus.swift b/Sources/ObjC-Support/Swift/Models/PLKExitStatus.swift new file mode 100644 index 0000000..c6bc243 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKExitStatus.swift @@ -0,0 +1,47 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension ExitStatus { + var toObjCValue: PLKExitStatusValue? { + switch self { + case .requiresQuestions: + return .requiresQuestions + + case .requiresSelections: + return .requiresSelections + + case .requiresCode: + return .requiresCode + + case .chooseDevice: + return .chooseDevice + + case .requiresCredentials: + return .requiresCredentials + + case .institutionNotFound: + return .institutionNotFound + + case .requiresAccountSelection: + return .requiresAccountSelection + + case .continueToThirdParty: + return .continueToThridParty + + case .unknown: + return nil + + @unknown default: + return nil + } + } + + var toObjC: PLKExitStatus { + guard let value = self.toObjCValue else { + return .create(withUnknownStringValue: description) + } + + return .create(with: value) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKInstitution.swift b/Sources/ObjC-Support/Swift/Models/PLKInstitution.swift new file mode 100644 index 0000000..f1e7560 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKInstitution.swift @@ -0,0 +1,9 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension Institution { + var toObjC: PLKInstitution { + return PLKInstitution(id: id, name: name) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKInstitutionErrorCode.swift b/Sources/ObjC-Support/Swift/Models/PLKInstitutionErrorCode.swift new file mode 100644 index 0000000..87cb6ff --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKInstitutionErrorCode.swift @@ -0,0 +1,22 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension InstitutionErrorCode { + var toObjC: PLKInstitutionErrorCode? { + switch self { + case .institutionDown: + return .institutionDown + case .institutionNotResponding: + return .institutionNotResponding + case .institutionNotAvailable: + return .institutionNotAvailable + case .institutionNoLongerSupported: + return .institutionNoLongerSupported + case .unknown: + return nil + @unknown default: + return nil + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKInvalidInputErrorCode.swift b/Sources/ObjC-Support/Swift/Models/PLKInvalidInputErrorCode.swift new file mode 100644 index 0000000..4711dec --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKInvalidInputErrorCode.swift @@ -0,0 +1,30 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension InvalidInputErrorCode { + var toObjC: PLKInvalidInputErrorCode? { + switch self { + case .invalidApiKeys: + return .invalidApiKeys + case .unauthorizedEnvironment: + return .unauthorizedEnvironment + case .invalidAccessToken: + return .invalidAccessToken + case .invalidPublicToken: + return .invalidPublicToken + case .invalidProduct: + return .invalidProduct + case .invalidAccountId: + return .invalidAccountId + case .invalidInstitution: + return .invalidInstitution + case .tooManyVerificationAttempts: + return .tooManyVerificationAttempts + case .unknown: + return nil + @unknown default: + return nil + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKInvalidRequestErrorCode.swift b/Sources/ObjC-Support/Swift/Models/PLKInvalidRequestErrorCode.swift new file mode 100644 index 0000000..3d1371e --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKInvalidRequestErrorCode.swift @@ -0,0 +1,28 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension InvalidRequestErrorCode { + var toObjC: PLKInvalidRequestErrorCode? { + switch self { + case .missingFields: + return .missingFields + case .unknownFields: + return .unknownFields + case .invalidField: + return .invalidField + case .invalidBody: + return .invalidBody + case .invalidAddress: + return .invalidAddress + case .notFound: + return .notFound + case .sandboxOnly: + return .sandboxOnly + case .unknown: + return nil + @unknown default: + return nil + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKItemErrorCode.swift b/Sources/ObjC-Support/Swift/Models/PLKItemErrorCode.swift new file mode 100644 index 0000000..ae68220 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKItemErrorCode.swift @@ -0,0 +1,52 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension ItemErrorCode { + var toObjC: PLKItemErrorCode? { + switch self { + case .insufficientCredentials: + return .insufficientCredentials + case .invalidCredentials: + return .invalidCredentials + case .invalidMfa: + return .invalidMfa + case .invalidSendMethod: + return .invalidSendMethod + case .invalidUpdatedUsername: + return .invalidUpdatedUsername + case .itemLocked: + return .itemLocked + case .itemLoginRequired: + return .itemLoginRequired + case .itemNoError: + return .itemNoError + case .itemNotSupported: + return .itemNotSupported + case .incorrectDepositAmounts: + return .incorrectDepositAmounts + case .userSetupRequired: + return .userSetupRequired + case .mfaNotSupported: + return .mfaNotSupported + case .noAccounts: + return .noAccounts + case .noAuthAccounts: + return .noAuthAccounts + case .noInvestmentAccounts: + return .noInvestmentAccounts + case .noLiabilityAccounts: + return .noLiabilityAccounts + case .productNotReady: + return .productNotReady + case .productsNotSupported: + return .productsNotSupported + case .instantMatchFailed: + return .instantMatchFailed + case .unknown: + return nil + @unknown default: + return nil + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKLayerTokenConfiguration.swift b/Sources/ObjC-Support/Swift/Models/PLKLayerTokenConfiguration.swift new file mode 100644 index 0000000..3a0a21c --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKLayerTokenConfiguration.swift @@ -0,0 +1,22 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension PLKLayerTokenConfiguration { + var toSwift: LayerTokenConfiguration { + let onSuccess: OnSuccessHandler = { success in + self.onSuccess(success.toObjC) + } + + let onExit: OnExitHandler = { exit in + self.onExit(exit.toObjC) + } + + let onEvent: OnEventHandler = { event in + self.onEvent(event.toObjC) + } + + let result = LayerTokenConfiguration(token: token, onSuccess: onSuccess, onExit: onExit, onEvent: onEvent) + return result + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKLinkEvent.swift b/Sources/ObjC-Support/Swift/Models/PLKLinkEvent.swift new file mode 100644 index 0000000..dc4262b --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKLinkEvent.swift @@ -0,0 +1,9 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension LinkEvent { + var toObjC: PLKLinkEvent { + return PLKLinkEvent(eventName: eventName.toObjC, eventMetadata: metadata.toObjC) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKLinkExit.swift b/Sources/ObjC-Support/Swift/Models/PLKLinkExit.swift new file mode 100644 index 0000000..5c37320 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKLinkExit.swift @@ -0,0 +1,9 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension LinkExit { + var toObjC: PLKLinkExit { + return PLKLinkExit(error: error?.toObjC, metadata: metadata.toObjC) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKLinkSuccess.swift b/Sources/ObjC-Support/Swift/Models/PLKLinkSuccess.swift new file mode 100644 index 0000000..a531693 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKLinkSuccess.swift @@ -0,0 +1,9 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension LinkSuccess { + var toObjC: PLKLinkSuccess { + return PLKLinkSuccess(publicToken: publicToken, metadata: metadata.toObjC) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKLinkTokenConfiguration.swift b/Sources/ObjC-Support/Swift/Models/PLKLinkTokenConfiguration.swift new file mode 100644 index 0000000..c58ee2c --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKLinkTokenConfiguration.swift @@ -0,0 +1,33 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension PLKLinkTokenConfiguration { + var toSwift: LinkTokenConfiguration { + let onSuccess: OnSuccessHandler = { success in + self.onSuccess(success.toObjC) + } + + let onExit: OnExitHandler = { exit in + self.onExit(exit.toObjC) + } + + let onEvent: OnEventHandler = { event in + self.onEvent(event.toObjC) + } + + let onLoad: OnLoadHandler = { + self.onLoad?() + } + + let result = LinkTokenConfiguration( + token: token, + onSuccess: onSuccess, + onExit: onExit, + onEvent: onEvent, + onLoad: onLoad + ) + + return result + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKMFAType.swift b/Sources/ObjC-Support/Swift/Models/PLKMFAType.swift new file mode 100644 index 0000000..19ab89d --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKMFAType.swift @@ -0,0 +1,20 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension MFAType { + var toObjC: PLKMFAType { + switch self { + case .code: + return .code + case .device: + return .device + case .questions: + return .questions + case .selections: + return .selections + @unknown default: + return .none + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKPlaidConfigurationError.swift b/Sources/ObjC-Support/Swift/Models/PLKPlaidConfigurationError.swift new file mode 100644 index 0000000..0523f7a --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKPlaidConfigurationError.swift @@ -0,0 +1,35 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +@objc enum PLKPlaidConfigurationError: Int { + case invalidToken +} + +extension ConfigurationError { + var toObjC: PLKPlaidConfigurationError { + switch self { + case .invalidToken: + return .invalidToken + @unknown default: + return .invalidToken + } + } + + var nsError: NSError { + switch self { + case .invalidToken(let message): + return NSError( + domain: kPLKDefaultErrorDomain, + code: self.toObjC.rawValue, + userInfo: ["message": message, NSLocalizedDescriptionKey: message] + ) + @unknown default: + return NSError( + domain: kPLKDefaultErrorDomain, + code: self.toObjC.rawValue, + userInfo: ["message": message, NSLocalizedDescriptionKey: "unknown default"] + ) + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKPlaidFinanceKit.swift b/Sources/ObjC-Support/Swift/Models/PLKPlaidFinanceKit.swift new file mode 100644 index 0000000..4b49ac0 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKPlaidFinanceKit.swift @@ -0,0 +1,110 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +// MARK: FinanceKit + +#if canImport(FinanceKit) && compiler(>=5.10) && !targetEnvironment(macCatalyst) +@available(iOS 17.4, *) +@objc public final class PLKPlaidFinanceKit: NSObject { + + /// Objective-C compatible version of SyncBehavior. + /// Note: Objective-C enums must inherit from Int. + @objc(PLKSyncBehavior) + public enum PLKSyncBehavior: Int { + + /// Performs a real sync using live FinanceKit data. + case live + + /// Simulates sync behavior for testing or local development. + case simulated + + /// Internal helper to convert back to the modern Swift-only enum if needed. + internal var swiftValue: PlaidFinanceKit.SyncBehavior { + switch self { + case .live: return .live + case .simulated: return .simulated + } + } + } + + @objc public static func syncFinanceKit( + token: String, + requestAuthorizationIfNeeded: Bool, + syncBehavior: PLKSyncBehavior, + onSuccess: @escaping () -> Void, + onError: @escaping (NSError) -> Void + ) { + PlaidFinanceKit.sync( + token: token, + requestAuthorizationIfNeeded: requestAuthorizationIfNeeded, + syncBehavior: syncBehavior.swiftValue, + completion: { result in + switch result { + case .success: + onSuccess() + case .failure(let error): + onError(error.toNSError) + } + } + ) + } +} + +@available(iOS 17.4, *) +@objc enum PLKPlaidFinanceKitError: Int { + case invalidToken + case permissionError + case linkApiError + case permissionAccessError + case unknown +} + +@available(iOS 17.4, *) +extension FinanceKitError { + var toObjC: PLKPlaidFinanceKitError { + switch self { + case .invalidToken: + return .invalidToken + case .permissionError: + return .permissionError + case .linkApiError: + return .linkApiError + case .permissionAccessError: + return .permissionAccessError + case .unknown: + return .unknown + @unknown default: + return .unknown + } + } + + var toNSError: NSError { + switch self { + case .invalidToken(let message), .permissionError(let message), + .linkApiError(let message), .permissionAccessError(let message): + return NSError( + domain: kPLKDefaultErrorDomain, + code: self.toObjC.rawValue, + userInfo: ["message": message, NSLocalizedDescriptionKey: message] + ) + case .unknown(let error): + return NSError( + domain: kPLKDefaultErrorDomain, + code: self.toObjC.rawValue, + userInfo: [ + "message": error.localizedDescription, NSLocalizedDescriptionKey: error.localizedDescription, + ] + ) + @unknown default: + return NSError( + domain: kPLKDefaultErrorDomain, + code: self.toObjC.rawValue, + userInfo: [ + "message": "unknown default", NSLocalizedDescriptionKey: "unknown default", + ] + ) + } + } +} +#endif // if canImport(FinanceKit) && compiler(>=5.10) && !targetEnvironment(macCatalyst) diff --git a/Sources/ObjC-Support/Swift/Models/PLKPlaidHeadlessSession.swift b/Sources/ObjC-Support/Swift/Models/PLKPlaidHeadlessSession.swift new file mode 100644 index 0000000..6c00b6b --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKPlaidHeadlessSession.swift @@ -0,0 +1,16 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +final class PLKPlaidHeadlessSessionShim: NSObject, PLKPlaidHeadlessSession { + + init(session: PlaidHeadlessSession) { + self.session = session + } + + func start() { + session.start() + } + + private let session: PlaidHeadlessSession +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKPlaidLayerSession.swift b/Sources/ObjC-Support/Swift/Models/PLKPlaidLayerSession.swift new file mode 100644 index 0000000..da1bfe2 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKPlaidLayerSession.swift @@ -0,0 +1,44 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +/// Protocol defining the required methods for a Layer session +protocol LayerSessionProtocol { + func submit(data: SubmissionData) + func open(using: PresentationMethod) +} + +/// Extension to make PlaidLayerSession conform to the protocol +extension PlaidLayerSession: LayerSessionProtocol {} + +final class PLKPlaidLayerSessionShim: NSObject, PLKPlaidLayerSession { + + init(session: LayerSessionProtocol) { + self.session = session + } + + func submit(_ data: PLKSubmissionData) { + session.submit(data: SwiftPLKSubmissionData(data: data)) + } + + func open( + presentationHandler: @escaping PLKPresentationHandler, + dismissalHandler: @escaping PLKDismissalHandler + ) { + session.open(using: .custom(presentationHandler, dismissalHandler)) + } + + internal struct SwiftPLKSubmissionData: SubmissionData { + var phoneNumber: String? + var dateOfBirth: String? + var params: [String: String]? + + init(data: PLKSubmissionData) { + phoneNumber = data.phoneNumber + dateOfBirth = data.dateOfBirth + params = data.params + } + } + + private let session: LayerSessionProtocol +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKPlaidLinkSession.swift b/Sources/ObjC-Support/Swift/Models/PLKPlaidLinkSession.swift new file mode 100644 index 0000000..e1c15c7 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKPlaidLinkSession.swift @@ -0,0 +1,34 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +/// Protocol defining the required methods for a Layer session +protocol LinkSessionProtocol { + var showGradientBackground: Bool { get set } + func open(using: PresentationMethod) +} + +/// Extension to make PlaidLayerSession conform to the protocol +extension PlaidLinkSession: LinkSessionProtocol {} + +final class PLKPlaidLinkSessionShim: NSObject, PLKPlaidLinkSession { + + init(session: LinkSessionProtocol) { + self.session = session + } + + var showGradientBackground: Bool = false { + didSet { + session.showGradientBackground = showGradientBackground + } + } + + func open( + presentationHandler: @escaping PLKPresentationHandler, + dismissalHandler: @escaping PLKDismissalHandler + ) { + session.open(using: .custom(presentationHandler, dismissalHandler)) + } + + private var session: LinkSessionProtocol +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKRateLimitErrorCode.swift b/Sources/ObjC-Support/Swift/Models/PLKRateLimitErrorCode.swift new file mode 100644 index 0000000..e375f94 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKRateLimitErrorCode.swift @@ -0,0 +1,30 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension RateLimitErrorCode { + var toObjC: PLKRateLimitErrorCode? { + switch self { + case .accountsLimit: + return .accountsLimit + case .additionLimit: + return .additionLimit + case .authLimit: + return .authLimit + case .identityLimit: + return .identityLimit + case .incomeLimit: + return .incomeLimit + case .itemGetLimit: + return .itemGetLimit + case .rateLimit: + return .rateLimit + case .transactionsLimit: + return .transactionsLimit + case .unknown: + return nil + @unknown default: + return nil + } + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKSuccessMetadata.swift b/Sources/ObjC-Support/Swift/Models/PLKSuccessMetadata.swift new file mode 100644 index 0000000..9f6d29c --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKSuccessMetadata.swift @@ -0,0 +1,14 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension SuccessMetadata { + var toObjC: PLKSuccessMetadata { + return PLKSuccessMetadata( + linkSessionID: linkSessionID, + institution: institution.toObjC, + accounts: accounts.map(\.toObjC), + metadataJSON: metadataJSON + ) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKVerificationStatus.swift b/Sources/ObjC-Support/Swift/Models/PLKVerificationStatus.swift new file mode 100644 index 0000000..e12a266 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKVerificationStatus.swift @@ -0,0 +1,28 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension VerificationStatus { + private var toObjCValue: PLKVerificationStatusValue? { + switch self { + case .pendingAutomaticVerification: + return .pendingAutomaticVerification + case .pendingManualVerification: + return .pendingManualVerification + case .manuallyVerified: + return .manuallyVerified + case .unknown: + return nil + @unknown default: + return nil + } + } + + var toObjC: PLKVerificationStatus { + guard let value = self.toObjCValue else { + return .create(withUnknownStringValue: description) + } + + return .create(with: value) + } +} diff --git a/Sources/ObjC-Support/Swift/Models/PLKViewName.swift b/Sources/ObjC-Support/Swift/Models/PLKViewName.swift new file mode 100644 index 0000000..32df794 --- /dev/null +++ b/Sources/ObjC-Support/Swift/Models/PLKViewName.swift @@ -0,0 +1,114 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +extension ViewName { + var toObjCValue: PLKViewNameValue? { + switch self { + case .acceptTOS: + return .acceptTOS + case .connected: + return .connected + case .consent: + return .consent + case .credential: + return .credential + case .documentaryVerification: + return .documentaryVerification + case .error: + return .error + case .exit: + return .exit + case .kycCheck: + return .kycCheck + case .loading: + return .loading + case .matchedConsent: + return .matchedConsent + case .matchedCredential: + return .matchedCredential + case .matchedMFA: + return .matchedMFA + case .mfa: + return .MFA + case .numbers: + return .numbers + case .profileDataReview: + return .profileDataReview + case .oauth: + return .oauth + case .recaptcha: + return .recaptcha + case .riskCheck: + return .riskCheck + case .screening: + return .screening + case .selectAccount: + return .selectAccount + case .selectInstitution: + return .selectInstitution + case .selfieCheck: + return .selfieCheck + case .submitDocuments: + return .submitDocuments + case .submitDocumentsSuccess: + return .submitDocumentsSuccess + case .submitDocumentsError: + return .submitDocumentsError + case .uploadDocuments: + return .uploadDocuments + case .verifySMS: + return .verifySMS + case .dataTransparency: + return .dataTransparency + case .dataTransparencyConsent: + return .dataTransparencyConsent + case .dataTransparencyMessagingModal: + return .dataTransparencyMessagingModal + case .identityMatchBlock: + return .identityMatchBlock + case .selectAuthType: + return .selectAuthType + case .selectBrand: + return .selectBrand + case .numbersSelectInstitution: + return .numbersSelectInstitution + case .submitEmail: + return .submitEmail + case .submitPhone: + return .submitPhone + case .verifyPhone: + return .verifyPhone + case .selectSavedInstitution: + return .selectSavedInstitution + case .selectSavedAccount: + return .selectSavedAccount + case .verifyEmail: + return .verifyEmail + case .craConsent: + return .craConsent + case .instantMicrodepositAuthorized: + return .instantMicrodepositAuthorized + case .sameDayMicrodepositAuthorized: + return .sameDayMicrodepositAuthorized + case .instantMicrodepositVerification: + return .instantMicrodepositVerification + case .sameDayMicrodepositVerification: + return .sameDayMicrodepositVerification + case .transferStatusCheck: + return .transferStatusCheck + case .unknown: + return nil + @unknown default: + return nil + } + } + + var toObjC: PLKViewName { + guard let value = self.toObjCValue else { + return .create(withUnknownStringValue: description) + } + + return .create(with: value) + } +} diff --git a/Sources/ObjC-Support/Swift/PLKPlaid.swift b/Sources/ObjC-Support/Swift/PLKPlaid.swift new file mode 100644 index 0000000..24a7a66 --- /dev/null +++ b/Sources/ObjC-Support/Swift/PLKPlaid.swift @@ -0,0 +1,46 @@ +import Foundation +import LinkKit +import LinkKitObjCInternal + +@objc public class PLKPlaid: NSObject { + + /// The version of the Plaid Link iOS SDK. + @objc public static var sdkVersion: String { Plaid.version } + + @objc public static func createPlaidLinkSession( + configuration: PLKLinkTokenConfiguration + ) throws -> PLKPlaidLinkSession { + let config = configuration.toSwift + let session = try Plaid.createPlaidLinkSession(configuration: config) + return PLKPlaidLinkSessionShim(session: session) + } + + @objc public static func createPlaidLayerSession( + configuration: PLKLayerTokenConfiguration + ) throws -> PLKPlaidLayerSession { + let config = configuration.toSwift + let session = try Plaid.createPlaidLayerSession(configuration: config) + return PLKPlaidLayerSessionShim(session: session) + } + + @objc public static func createHeadlessSession( + configuration: PLKLinkTokenConfiguration + ) throws -> PLKPlaidHeadlessSession { + let config = configuration.toSwift + let session = try Plaid.createHeadlessSession(configuration: config) + return PLKPlaidHeadlessSessionShim(session: session) + } + + @objc public static func createEmbeddedLinkUIView( + configuration: PLKEmbeddedLinkTokenConfiguration, + presentationHandler: @escaping PLKPresentationHandler, + dismissalHandler: @escaping PLKDismissalHandler + ) throws -> UIView { + let config = configuration.toSwift + let view = try Plaid.createEmbeddedLinkUIView( + configuration: config, + presentationMethod: .custom(presentationHandler, dismissalHandler) + ) + return view + } +} diff --git a/Tests/LinkKitObjCTests/Mocks/MockLayerSession.swift b/Tests/LinkKitObjCTests/Mocks/MockLayerSession.swift new file mode 100644 index 0000000..4db9ae3 --- /dev/null +++ b/Tests/LinkKitObjCTests/Mocks/MockLayerSession.swift @@ -0,0 +1,26 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +class MockLayerSession: LayerSessionProtocol { + + private(set) var openCalled = false + private(set) var openCallCount = 0 + private(set) var submitCalled = false + private(set) var submitCallCount = 0 + private(set) var lastPresentationMethod: PresentationMethod? + private(set) var lastSubmittedData: SubmissionData? + + func submit(data: any LinkKit.SubmissionData) { + submitCalled = true + submitCallCount += 1 + lastSubmittedData = data + } + + func open(using: LinkKit.PresentationMethod) { + openCalled = true + openCallCount += 1 + lastPresentationMethod = using + } +} diff --git a/Tests/LinkKitObjCTests/Mocks/MockPlaidLinkSession.swift b/Tests/LinkKitObjCTests/Mocks/MockPlaidLinkSession.swift new file mode 100644 index 0000000..80ed090 --- /dev/null +++ b/Tests/LinkKitObjCTests/Mocks/MockPlaidLinkSession.swift @@ -0,0 +1,18 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +class MockPlaidLinkSession: LinkSessionProtocol { + private(set) var openCalled = false + private(set) var openCallCount = 0 + private(set) var lastPresentationMethod: PresentationMethod? + + var showGradientBackground: Bool = false + + func open(using method: PresentationMethod) { + openCalled = true + openCallCount += 1 + lastPresentationMethod = method + } +} diff --git a/Tests/LinkKitObjCTests/PLKAccountSubtypeTests.swift b/Tests/LinkKitObjCTests/PLKAccountSubtypeTests.swift new file mode 100644 index 0000000..79253b7 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKAccountSubtypeTests.swift @@ -0,0 +1,279 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKAccountSubtypeTests: XCTestCase { + + func testCreditCardToObjC() { + let subtype = AccountSubtype.Credit.creditCard + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .creditCard) + } + + func testCreditPaypalToObjC() { + let subtype = AccountSubtype.Credit.paypal + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .paypal) + } + + func testCreditAllToObjC() { + let subtype = AccountSubtype.Credit.all + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .all) + } + + func testCreditUnknownToObjC() { + let subtype = AccountSubtype.Credit.unknown("custom_credit") + let objcSubtype = subtype.toObjC + XCTAssertNil(objcSubtype) + } + + // MARK: - Loan Subtype Tests + + func testLoanMortgageToObjC() { + let subtype = AccountSubtype.Loan.mortgage + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .mortgage) + } + + func testLoanAutoToObjC() { + let subtype = AccountSubtype.Loan.auto + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .auto) + } + + func testLoanStudentToObjC() { + let subtype = AccountSubtype.Loan.student + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .student) + } + + func testLoanHomeEquityToObjC() { + let subtype = AccountSubtype.Loan.homeEquity + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .homeEquity) + } + + func testLoanLineOfCreditToObjC() { + let subtype = AccountSubtype.Loan.lineOfCredit + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .lineOfCredit) + } + + func testLoanAllToObjC() { + let subtype = AccountSubtype.Loan.all + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .all) + } + + func testLoanUnknownToObjC() { + let subtype = AccountSubtype.Loan.unknown("custom_loan") + let objcSubtype = subtype.toObjC + XCTAssertNil(objcSubtype) + } + + // MARK: - Depository Subtype Tests + + func testDepositoryCheckingToObjC() { + let subtype = AccountSubtype.Depository.checking + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .checking) + } + + func testDepositorySavingsToObjC() { + let subtype = AccountSubtype.Depository.savings + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .savings) + } + + func testDepositoryMoneyMarketToObjC() { + let subtype = AccountSubtype.Depository.moneyMarket + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .moneyMarket) + } + + func testDepositoryCdToObjC() { + let subtype = AccountSubtype.Depository.cd + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .cd) + } + + func testDepositoryCashManagementToObjC() { + let subtype = AccountSubtype.Depository.cashManagement + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .cashManagement) + } + + func testDepositoryPaypalToObjC() { + let subtype = AccountSubtype.Depository.paypal + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .paypal) + } + + func testDepositoryPrepaidToObjC() { + let subtype = AccountSubtype.Depository.prepaid + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .prepaid) + } + + func testDepositoryAllToObjC() { + let subtype = AccountSubtype.Depository.all + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .all) + } + + func testDepositoryUnknownToObjC() { + let subtype = AccountSubtype.Depository.unknown("custom_depository") + let objcSubtype = subtype.toObjC + XCTAssertNil(objcSubtype) + } + + // MARK: - Investment Subtype Tests + + func testInvestment401kToObjC() { + let subtype = AccountSubtype.Investment.investment401k + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investment401k) + } + + func testInvestment401aToObjC() { + let subtype = AccountSubtype.Investment.investment401a + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investment401a) + } + + func testInvestment403BToObjC() { + let subtype = AccountSubtype.Investment.investment403B + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investment403B) + } + + func testInvestment457bToObjC() { + let subtype = AccountSubtype.Investment.investment457b + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investment457b) + } + + func testInvestment529ToObjC() { + let subtype = AccountSubtype.Investment.investment529 + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investment529) + } + + func testInvestmentBrokerageToObjC() { + let subtype = AccountSubtype.Investment.brokerage + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investmentBrokerage) + } + + func testInvestmentIraToObjC() { + let subtype = AccountSubtype.Investment.ira + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investmentIra) + } + + func testInvestmentRothToObjC() { + let subtype = AccountSubtype.Investment.roth + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investmentRoth) + } + + func testInvestmentRoth401kToObjC() { + let subtype = AccountSubtype.Investment.roth401k + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investmentRoth401k) + } + + func testInvestmentMutualFundToObjC() { + let subtype = AccountSubtype.Investment.mutualFund + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investmentMutualFund) + } + + func testInvestmentPensionToObjC() { + let subtype = AccountSubtype.Investment.pension + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investmentPension) + } + + func testInvestmentAllToObjC() { + let subtype = AccountSubtype.Investment.all + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype, .investmentAll) + } + + func testInvestmentUnknownToObjC() { + let subtype = AccountSubtype.Investment.unknown("custom_investment") + let objcSubtype = subtype.toObjC + XCTAssertNil(objcSubtype) + } + + // MARK: - Other Subtype Tests + + func testOtherAllToObjC() { + let subtype: AccountSubtype = .other(.all) + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype.rawStringValue, "all") + } + + func testOtherOtherToObjC() { + let subtype: AccountSubtype = .other(.other) + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype.rawStringValue, "other") + } + + func testOtherUnknownToObjC() { + let subtype: AccountSubtype = .other(.unknown("custom_other")) + let objcSubtype = subtype.toObjC + XCTAssertEqual(objcSubtype.rawStringValue, "custom_other") + } + + // MARK: - AccountSubtype (Full Enum) Tests + + func testAccountSubtypeCreditToObjC() { + let subtype: AccountSubtype = .credit(.creditCard) + let objcSubtype = subtype.toObjC + XCTAssertTrue(objcSubtype is PLKAccountSubtypeCredit) + XCTAssertEqual(objcSubtype.rawStringValue, "credit card") + } + + func testAccountSubtypeLoanToObjC() { + let subtype: AccountSubtype = .loan(.mortgage) + let objcSubtype = subtype.toObjC + XCTAssertTrue(objcSubtype is PLKAccountSubtypeLoan) + XCTAssertEqual(objcSubtype.rawStringValue, "mortgage") + } + + func testAccountSubtypeDepositoryToObjC() { + let subtype: AccountSubtype = .depository(.checking) + let objcSubtype = subtype.toObjC + XCTAssertTrue(objcSubtype is PLKAccountSubtypeDepository) + XCTAssertEqual(objcSubtype.rawStringValue, "checking") + } + + func testAccountSubtypeInvestmentToObjC() { + let subtype: AccountSubtype = .investment(.investment401k) + let objcSubtype = subtype.toObjC + XCTAssertTrue(objcSubtype is PLKAccountSubtypeInvestment) + XCTAssertEqual(objcSubtype.rawStringValue, "401k") + } + + func testAccountSubtypeOtherToObjC() { + let subtype: AccountSubtype = .other(.all) + let objcSubtype = subtype.toObjC + XCTAssertTrue(objcSubtype is PLKAccountSubtypeOther) + XCTAssertEqual(objcSubtype.rawStringValue, "all") + } + + func testAccountSubtypeUnknownToObjC() { + let subtype: AccountSubtype = .unknown(type: "custom_type", subtype: "custom_subtype") + let objcSubtype = subtype.toObjC + XCTAssertTrue(objcSubtype is PLKAccountSubtypeUnknown) + if let unknownSubtype = objcSubtype as? PLKAccountSubtypeUnknown { + XCTAssertEqual(unknownSubtype.rawStringValue, "custom_type") + XCTAssertEqual(unknownSubtype.rawSubtypeStringValue, "custom_subtype") + } + } +} diff --git a/Tests/LinkKitObjCTests/PLKAccountTests.swift b/Tests/LinkKitObjCTests/PLKAccountTests.swift new file mode 100644 index 0000000..d4a90b9 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKAccountTests.swift @@ -0,0 +1,37 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKAccountTests: XCTestCase { + + func testAccountToObjC() throws { + let jsonString = """ + { + "id": "acc_123456789", + "name": "Plaid Checking", + "mask": "0000", + "subtype": "depository.checking", + "verificationStatus": "manually_verified" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let account = try decoder.decode(Account.self, from: data) + + XCTAssertEqual(account.id, "acc_123456789") + XCTAssertEqual(account.name, "Plaid Checking") + XCTAssertEqual(account.mask, "0000") + XCTAssertEqual(account.subtype, .depository(.checking)) + XCTAssertEqual(account.verificationStatus, .manuallyVerified) + + let objcAccount = account.toObjC + + XCTAssertEqual(objcAccount.id, account.id) + XCTAssertEqual(objcAccount.name, account.name) + XCTAssertEqual(objcAccount.mask, account.mask) + XCTAssertEqual(objcAccount.subtype.rawStringValue, account.subtype.toObjC.rawStringValue) + XCTAssertEqual(objcAccount.verificationStatus?.value, account.verificationStatus?.toObjC.value) + } +} diff --git a/Tests/LinkKitObjCTests/PLKApiErrorCodeTests.swift b/Tests/LinkKitObjCTests/PLKApiErrorCodeTests.swift new file mode 100644 index 0000000..7c39d25 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKApiErrorCodeTests.swift @@ -0,0 +1,25 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKApiErrorCodeTests: XCTestCase { + + func testInternalServerErrorToObjC() { + let errorCode = ApiErrorCode.internalServerError + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .internalServerError) + } + + func testPlannedMaintenanceToObjC() { + let errorCode = ApiErrorCode.plannedMaintenance + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .plannedMaintenance) + } + + func testUnknownToObjC() { + let errorCode = ApiErrorCode.unknown("CUSTOM_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } +} diff --git a/Tests/LinkKitObjCTests/PLKAssetReportErrorCodeTests.swift b/Tests/LinkKitObjCTests/PLKAssetReportErrorCodeTests.swift new file mode 100644 index 0000000..3f20d51 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKAssetReportErrorCodeTests.swift @@ -0,0 +1,57 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKAssetReportErrorCodeTests: XCTestCase { + + // MARK: - toObjC Conversion Tests + + func testProductNotEnabledToObjC() { + let errorCode = AssetReportErrorCode.productNotEnabled + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .productNotEnabled) + } + + func testDataUnavailableToObjC() { + let errorCode = AssetReportErrorCode.dataUnavailable + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .dataUnavailable) + } + + func testProductNotReadyToObjC() { + let errorCode = AssetReportErrorCode.productNotReady + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .productNotReady) + } + + func testAssetReportGenerationFailedToObjC() { + let errorCode = AssetReportErrorCode.assetReportGenerationFailed + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .assetReportGenerationFailed) + } + + func testInvalidParentToObjC() { + let errorCode = AssetReportErrorCode.invalidParent + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidParent) + } + + func testInsightsNotEnabledToObjC() { + let errorCode = AssetReportErrorCode.insightsNotEnabled + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .insightsNotEnabled) + } + + func testInsightsPreviouslyNotEnabledToObjC() { + let errorCode = AssetReportErrorCode.insightsPreviouslyNotEnabled + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .insightsPreviouslyNotEnabled) + } + + func testUnknownToObjC() { + let errorCode = AssetReportErrorCode.unknown("CUSTOM_ASSET_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } +} diff --git a/Tests/LinkKitObjCTests/PLKAuthErrorCodeTests.swift b/Tests/LinkKitObjCTests/PLKAuthErrorCodeTests.swift new file mode 100644 index 0000000..8d763b4 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKAuthErrorCodeTests.swift @@ -0,0 +1,25 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKAuthErrorCodeTests: XCTestCase { + + func testProductNotReadyToObjC() { + let errorCode = AuthErrorCode.productNotReady + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .productNotReady) + } + + func testVerificationExpiredToObjC() { + let errorCode = AuthErrorCode.verificationExpired + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .verificationExpired) + } + + func testUnknownToObjC() { + let errorCode = AuthErrorCode.unknown("CUSTOM_AUTH_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } +} diff --git a/Tests/LinkKitObjCTests/PLKEmbeddedLinkTokenConfigurationTests.swift b/Tests/LinkKitObjCTests/PLKEmbeddedLinkTokenConfigurationTests.swift new file mode 100644 index 0000000..7c10410 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKEmbeddedLinkTokenConfigurationTests.swift @@ -0,0 +1,166 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKEmbeddedLinkTokenConfigurationTests: XCTestCase { + + func testTokenIsPassedThrough() { + let config = PLKEmbeddedLinkTokenConfiguration( + token: "test-token-123", + onSuccess: { _ in } + ) + + let swiftConfig = config.toSwift + XCTAssertEqual(swiftConfig.token, "test-token-123") + } + + func testOnSuccessHandlerIsCalled() throws { + var capturedObjCSuccess: PLKLinkSuccess? + let config = PLKEmbeddedLinkTokenConfiguration( + token: "test-token", + onSuccess: { success in + capturedObjCSuccess = success + } + ) + + let swiftConfig = config.toSwift + + // Create Swift LinkSuccess from JSON + let jsonString = """ + { + "public_token": "public-token-123", + "metadata": { + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "accounts": [ + { + "id": "acc_1", + "name": "Checking", + "mask": "1234", + "subtype": "depository.checking" + } + ], + "link_session_id": "session_123" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkSuccess = try decoder.decode(LinkSuccess.self, from: data) + + // Call the Swift onSuccess handler + swiftConfig.onSuccess(linkSuccess) + + // Verify ObjC handler was called with converted data + XCTAssertNotNil(capturedObjCSuccess) + XCTAssertEqual(capturedObjCSuccess?.publicToken, "public-token-123") + XCTAssertEqual(capturedObjCSuccess?.metadata.linkSessionID, "session_123") + XCTAssertEqual(capturedObjCSuccess?.metadata.institution.id, "ins_123") + XCTAssertEqual(capturedObjCSuccess?.metadata.institution.name, "Test Bank") + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.count, 1) + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.first?.id, "acc_1") + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.first?.name, "Checking") + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.first?.mask, "1234") + } + + func testOnExitHandlerIsCalled() throws { + var capturedObjCExit: PLKLinkExit? + let config = PLKEmbeddedLinkTokenConfiguration( + token: "test-token", + onSuccess: { _ in } + ) + config.onExit = { exit in + capturedObjCExit = exit + } + + let swiftConfig = config.toSwift + + // Create Swift LinkExit from JSON + let jsonString = """ + { + "error": { + "errorCode": { + "type": "ITEM_ERROR", + "code": "ITEM_LOGIN_REQUIRED" + }, + "errorMessage": "Item login required", + "displayMessage": "Please log in again" + }, + "metadata": { + "status": "requires_credentials", + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "link_session_id": "session_123", + "request_id": "req_123" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + // Call the Swift onExit handler + swiftConfig.onExit?(linkExit) + + // Verify ObjC handler was called with converted data + XCTAssertNotNil(capturedObjCExit) + XCTAssertEqual(capturedObjCExit?.metadata.linkSessionID, "session_123") + XCTAssertEqual(capturedObjCExit?.metadata.requestID, "req_123") + XCTAssertEqual(capturedObjCExit?.metadata.institution?.id, "ins_123") + XCTAssertEqual(capturedObjCExit?.metadata.institution?.name, "Test Bank") + XCTAssertNotNil(capturedObjCExit?.error) + XCTAssertEqual(capturedObjCExit?.error?.localizedDescription, "Please log in again") + } + + func testOnEventHandlerIsCalled() throws { + var capturedObjCEvent: PLKLinkEvent? + let config = PLKEmbeddedLinkTokenConfiguration( + token: "test-token", + onSuccess: { _ in } + ) + + config.onEvent = { event in + capturedObjCEvent = event + } + + let swiftConfig = config.toSwift + + // Create Swift LinkEvent from JSON + let jsonString = """ + { + "event_name": "OPEN", + "metadata": { + "link_session_id": "session_123", + "request_id": "req_123", + "institution_id": "ins_123", + "institution_name": "Test Bank", + "view_name": "SELECT_INSTITUTION", + "timestamp": "2024-01-21T12:00:00.000Z" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkEvent = try decoder.decode(LinkEvent.self, from: data) + + // Call the Swift onEvent handler + swiftConfig.onEvent?(linkEvent) + + // Verify ObjC handler was called with converted data + XCTAssertNotNil(capturedObjCEvent) + XCTAssertEqual(capturedObjCEvent?.eventName.value, .open) + XCTAssertEqual(capturedObjCEvent?.eventMetadata.linkSessionID, "session_123") + XCTAssertEqual(capturedObjCEvent?.eventMetadata.requestID, "req_123") + XCTAssertEqual(capturedObjCEvent?.eventMetadata.institutionID, "ins_123") + XCTAssertEqual(capturedObjCEvent?.eventMetadata.institutionName, "Test Bank") + } +} diff --git a/Tests/LinkKitObjCTests/PLKEnvironmentTests.swift b/Tests/LinkKitObjCTests/PLKEnvironmentTests.swift new file mode 100644 index 0000000..4baa788 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKEnvironmentTests.swift @@ -0,0 +1,20 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKEnvironmentTests: XCTestCase { + + func testProductionToSwift() { + let environment = PLKEnvironment.production + let swiftEnvironment = environment.toSwift + XCTAssertEqual(swiftEnvironment, .production) + } + + func testSandboxToSwift() { + let environment = PLKEnvironment.sandbox + let swiftEnvironment = environment.toSwift + XCTAssertEqual(swiftEnvironment, .sandbox) + } +} diff --git a/Tests/LinkKitObjCTests/PLKEventMetadataTests.swift b/Tests/LinkKitObjCTests/PLKEventMetadataTests.swift new file mode 100644 index 0000000..7429d4d --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKEventMetadataTests.swift @@ -0,0 +1,283 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKEventMetadataTests: XCTestCase { + + func testEventMetadataWithAllFieldsToObjC() throws { + let jsonString = """ + { + "link_session_id": "session_123", + "timestamp": "2024-01-21T12:00:00.000Z", + "institution_id": "ins_123", + "institution_name": "Test Bank", + "institution_search_query": "test bank", + "request_id": "req_123", + "error_code": { + "type": "ITEM_ERROR", + "code": "ITEM_LOGIN_REQUIRED" + }, + "error_message": "Item login required", + "exit_status": "requires_credentials", + "mfa_type": "code", + "view_name": "CREDENTIAL", + "account_number_mask": "1234", + "is_update_mode": "true", + "match_reason": "returning_user", + "routing_number": "021000021", + "selection": "phoneotp", + "issue_id": "issue_123", + "issue_description": "Test issue", + "issue_detected_at": "2024-01-21T11:30:00.000Z", + "metadata_json": "{\\"custom\\":\\"data\\"}" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let eventMetadata = try decoder.decode(EventMetadata.self, from: data) + + XCTAssertEqual(eventMetadata.linkSessionID, "session_123") + XCTAssertEqual(eventMetadata.institutionID, "ins_123") + XCTAssertEqual(eventMetadata.institutionName, "Test Bank") + XCTAssertEqual(eventMetadata.institutionSearchQuery, "test bank") + XCTAssertEqual(eventMetadata.requestID, "req_123") + XCTAssertEqual(eventMetadata.errorMessage, "Item login required") + XCTAssertEqual(eventMetadata.exitStatus, .requiresCredentials) + XCTAssertEqual(eventMetadata.mfaType, .code) + XCTAssertEqual(eventMetadata.viewName, .credential) + XCTAssertEqual(eventMetadata.accountNumberMask, "1234") + XCTAssertEqual(eventMetadata.isUpdateMode, "true") + XCTAssertEqual(eventMetadata.matchReason, "returning_user") + XCTAssertEqual(eventMetadata.routingNumber, "021000021") + XCTAssertEqual(eventMetadata.selection, "phoneotp") + XCTAssertEqual(eventMetadata.issueID, "issue_123") + XCTAssertEqual(eventMetadata.issueDescription, "Test issue") + XCTAssertNotNil(eventMetadata.issueDetectedAt) + XCTAssertEqual(eventMetadata.metadataJSON, "{\"custom\":\"data\"}") + + let objcEventMetadata = eventMetadata.toObjC + + XCTAssertEqual(objcEventMetadata.linkSessionID, eventMetadata.linkSessionID) + XCTAssertEqual(objcEventMetadata.institutionID, eventMetadata.institutionID) + XCTAssertEqual(objcEventMetadata.institutionName, eventMetadata.institutionName) + XCTAssertEqual(objcEventMetadata.institutionSearchQuery, eventMetadata.institutionSearchQuery) + XCTAssertEqual(objcEventMetadata.requestID, eventMetadata.requestID) + XCTAssertEqual(objcEventMetadata.accountNumberMask, eventMetadata.accountNumberMask) + XCTAssertEqual(objcEventMetadata.isUpdateMode, eventMetadata.isUpdateMode) + XCTAssertEqual(objcEventMetadata.matchReason, eventMetadata.matchReason) + XCTAssertEqual(objcEventMetadata.routingNumber, eventMetadata.routingNumber) + XCTAssertEqual(objcEventMetadata.selection, eventMetadata.selection) + XCTAssertEqual(objcEventMetadata.issueID, eventMetadata.issueID) + XCTAssertEqual(objcEventMetadata.issueDescription, eventMetadata.issueDescription) + XCTAssertEqual(objcEventMetadata.issueDetectedAt, eventMetadata.issueDetectedAt) + XCTAssertEqual(objcEventMetadata.metadataJSON, eventMetadata.metadataJSON) + XCTAssertEqual(objcEventMetadata.timestamp, eventMetadata.timestamp) + XCTAssertNotNil(objcEventMetadata.error) + XCTAssertEqual(objcEventMetadata.mfaType, .code) + XCTAssertEqual(objcEventMetadata.exitStatus?.value, .requiresCredentials) + XCTAssertEqual(objcEventMetadata.viewName?.value, .credential) + } + + func testEventMetadataWithMinimalFieldsToObjC() throws { + let jsonString = """ + { + "link_session_id": "session_minimal", + "timestamp": "2024-01-21T12:00:00.000Z" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let eventMetadata = try decoder.decode(EventMetadata.self, from: data) + + XCTAssertEqual(eventMetadata.linkSessionID, "session_minimal") + XCTAssertNotNil(eventMetadata.timestamp) + XCTAssertNil(eventMetadata.institutionID) + XCTAssertNil(eventMetadata.institutionName) + XCTAssertNil(eventMetadata.errorCode) + XCTAssertNil(eventMetadata.errorMessage) + XCTAssertNil(eventMetadata.exitStatus) + XCTAssertNil(eventMetadata.mfaType) + XCTAssertNil(eventMetadata.viewName) + + let objcEventMetadata = eventMetadata.toObjC + + XCTAssertEqual(objcEventMetadata.linkSessionID, "session_minimal") + XCTAssertEqual(objcEventMetadata.timestamp, eventMetadata.timestamp) + XCTAssertNil(objcEventMetadata.institutionID) + XCTAssertNil(objcEventMetadata.institutionName) + XCTAssertNil(objcEventMetadata.error) + XCTAssertNil(objcEventMetadata.exitStatus) + XCTAssertEqual(objcEventMetadata.mfaType, .none) + XCTAssertNil(objcEventMetadata.viewName) + } + + func testEventMetadataWithMFATypesToObjC() throws { + let testCases: [(String, MFAType, PLKMFAType)] = [ + ("code", .code, .code), + ("device", .device, .device), + ("questions", .questions, .questions), + ("selections", .selections, .selections), + ] + + for (jsonValue, swiftValue, objcValue) in testCases { + let jsonString = """ + { + "link_session_id": "session_mfa_test", + "timestamp": "2024-01-21T12:00:00.000Z", + "mfa_type": "\(jsonValue)" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let eventMetadata = try decoder.decode(EventMetadata.self, from: data) + + XCTAssertEqual(eventMetadata.mfaType, swiftValue) + + let objcEventMetadata = eventMetadata.toObjC + XCTAssertEqual(objcEventMetadata.mfaType, objcValue) + } + } + + func testEventMetadataWithExitStatusToObjC() throws { + let testCases: [(String, ExitStatus, PLKExitStatusValue)] = [ + ("requires_questions", .requiresQuestions, .requiresQuestions), + ("requires_selections", .requiresSelections, .requiresSelections), + ("requires_code", .requiresCode, .requiresCode), + ("choose_device", .chooseDevice, .chooseDevice), + ("requires_credentials", .requiresCredentials, .requiresCredentials), + ("institution_not_found", .institutionNotFound, .institutionNotFound), + ("requires_account_selection", .requiresAccountSelection, .requiresAccountSelection), + ] + + for (jsonValue, swiftValue, objcValue) in testCases { + let jsonString = """ + { + "link_session_id": "session_exit_test", + "timestamp": "2024-01-21T12:00:00.000Z", + "exit_status": "\(jsonValue)" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let eventMetadata = try decoder.decode(EventMetadata.self, from: data) + + XCTAssertEqual(eventMetadata.exitStatus, swiftValue) + + let objcEventMetadata = eventMetadata.toObjC + XCTAssertEqual(objcEventMetadata.exitStatus?.value, objcValue) + } + } + + func testEventMetadataWithViewNameToObjC() throws { + let testCases: [(String, ViewName, PLKViewNameValue)] = [ + ("CONNECTED", .connected, .connected), + ("CONSENT", .consent, .consent), + ("CREDENTIAL", .credential, .credential), + ("ERROR", .error, .error), + ("EXIT", .exit, .exit), + ("LOADING", .loading, .loading), + ("MFA", .mfa, .MFA), + ("SELECT_ACCOUNT", .selectAccount, .selectAccount), + ("SELECT_INSTITUTION", .selectInstitution, .selectInstitution), + ] + + for (jsonValue, swiftValue, objcValue) in testCases { + let jsonString = """ + { + "link_session_id": "session_view_test", + "timestamp": "2024-01-21T12:00:00.000Z", + "view_name": "\(jsonValue)" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let eventMetadata = try decoder.decode(EventMetadata.self, from: data) + + XCTAssertEqual(eventMetadata.viewName, swiftValue) + + let objcEventMetadata = eventMetadata.toObjC + XCTAssertEqual(objcEventMetadata.viewName?.value, objcValue) + } + } + + func testEventMetadataWithErrorCodeToObjC() throws { + let jsonString = """ + { + "link_session_id": "session_error", + "timestamp": "2024-01-21T12:00:00.000Z", + "error_code": { + "type": "ITEM_ERROR", + "code": "ITEM_LOGIN_REQUIRED" + }, + "error_message": "Login credentials are invalid" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let eventMetadata = try decoder.decode(EventMetadata.self, from: data) + + XCTAssertNotNil(eventMetadata.errorCode) + XCTAssertEqual(eventMetadata.errorMessage, "Login credentials are invalid") + + let objcEventMetadata = eventMetadata.toObjC + + let error = try XCTUnwrap(objcEventMetadata.error as NSError?, "Error should not be nil") + let errorMessage = error.userInfo[kPLKExitErrorMessageKey] as? String + XCTAssertEqual(errorMessage, "Login credentials are invalid") + } + + func testEventMetadataWithIssueDatesToObjC() throws { + let jsonString = """ + { + "link_session_id": "session_issue", + "timestamp": "2024-01-21T12:00:00.000Z", + "issue_id": "known_issue_123", + "issue_description": "Institution is experiencing downtime", + "issue_detected_at": "2024-01-21T10:00:00.000Z" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let eventMetadata = try decoder.decode(EventMetadata.self, from: data) + + XCTAssertEqual(eventMetadata.issueID, "known_issue_123") + XCTAssertEqual(eventMetadata.issueDescription, "Institution is experiencing downtime") + XCTAssertNotNil(eventMetadata.issueDetectedAt) + + let objcEventMetadata = eventMetadata.toObjC + + XCTAssertEqual(objcEventMetadata.issueID, "known_issue_123") + XCTAssertEqual(objcEventMetadata.issueDescription, "Institution is experiencing downtime") + XCTAssertEqual(objcEventMetadata.issueDetectedAt, eventMetadata.issueDetectedAt) + } + + func testEventMetadataWithNullErrorCodeToObjC() throws { + let jsonString = """ + { + "link_session_id": "session_no_error", + "timestamp": "2024-01-21T12:00:00.000Z", + "institution_id": "ins_123" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let eventMetadata = try decoder.decode(EventMetadata.self, from: data) + + XCTAssertNil(eventMetadata.errorCode) + XCTAssertNil(eventMetadata.errorMessage) + + let objcEventMetadata = eventMetadata.toObjC + + XCTAssertNil(objcEventMetadata.error) + } +} diff --git a/Tests/LinkKitObjCTests/PLKEventNameTests.swift b/Tests/LinkKitObjCTests/PLKEventNameTests.swift new file mode 100644 index 0000000..435f22c --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKEventNameTests.swift @@ -0,0 +1,100 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKEventNameTests: XCTestCase { + + // MARK: - Specific Conversion Tests + + func testOpenToObjC() { + let eventName = EventName.open + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .open) + } + + func testCloseOAuthToObjC() { + let eventName = EventName.closeOAuth + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .closeOAuth) + } + + func testErrorToObjC() { + let eventName = EventName.error + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .error) + } + + func testExitToObjC() { + let eventName = EventName.exit + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .exit) + } + + func testHandoffToObjC() { + let eventName = EventName.handoff + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .handoff) + } + + func testSelectInstitutionToObjC() { + let eventName = EventName.selectInstitution + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .selectInstitution) + } + + func testSubmitCredentialsToObjC() { + let eventName = EventName.submitCredentials + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .submitCredentials) + } + + func testTransitionViewToObjC() { + let eventName = EventName.transitionView + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .transitionView) + } + + func testUnknownToObjC() { + let eventName = EventName.unknown("CUSTOM_EVENT") + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .none) + XCTAssertEqual(objcEventName.unknownStringValue, "CUSTOM_EVENT") + } + + // MARK: - Comprehensive Mapping Tests + + func testAllKnownEventNamesHaveObjCMapping() { + for eventName in EventName.allCases { + let objcEventName = eventName.toObjC + + // All known cases should have a valid ObjC mapping (not PLKEventNameValueNone) + XCTAssertNotEqual( + objcEventName.value, + PLKEventNameValue.none, + "Event '\(eventName)' must have a valid ObjC mapping, but got .none" + ) + } + } + + func testSelectSavedInstitutionMapping() { + let eventName = EventName.selectSavedInstitution + let objcEventName = eventName.toObjC + XCTAssertEqual(objcEventName.value, .selectSavedInstitution) + } + + func testMultipleSwiftCasesCanMapToSameObjCValue() { + // Some Swift cases intentionally map to the same ObjC value + // For example, selectDegradedInstitution, selectDownInstitution, and selectInstitution + // all map to .selectInstitution in ObjC + + let degraded = EventName.selectDegradedInstitution.toObjC + let down = EventName.selectDownInstitution.toObjC + let normal = EventName.selectInstitution.toObjC + + XCTAssertEqual(degraded.value, .selectInstitution) + XCTAssertEqual(down.value, .selectInstitution) + XCTAssertEqual(normal.value, .selectInstitution) + } +} diff --git a/Tests/LinkKitObjCTests/PLKExitErrorTests.swift b/Tests/LinkKitObjCTests/PLKExitErrorTests.swift new file mode 100644 index 0000000..d56f124 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKExitErrorTests.swift @@ -0,0 +1,343 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKExitErrorTests: XCTestCase { + + // MARK: - Item Error Tests + + func testItemErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .itemError(.itemLoginRequired), + errorMessage: "Item login required", + displayMesssage: "Please log in again", + errorJSON: "{\"status_code\":400}" + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorItemDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Item login required") + XCTAssertEqual(nsError.userInfo[kPLKExitErrorDisplayMessageKey] as? String, "Please log in again") + XCTAssertEqual(nsError.userInfo[NSLocalizedDescriptionKey] as? String, "Please log in again") + XCTAssertEqual(nsError.userInfo[kPLKExitErrorRawJSONKey] as? String, "{\"status_code\":400}") + XCTAssertEqual(nsError.userInfo[kPLKExitErrorCodeKey] as? String, "ITEM_LOGIN_REQUIRED") + XCTAssertNotEqual(nsError.code, -1) + } + + func testItemErrorWithoutDisplayMessageToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .itemError(.itemLoginRequired), + errorMessage: "Item login required", + displayMesssage: nil, + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorItemDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Item login required") + XCTAssertEqual(nsError.userInfo[kPLKExitErrorDisplayMessageKey] as? String, "") + XCTAssertEqual(nsError.userInfo[NSLocalizedDescriptionKey] as? String, "") + XCTAssertEqual(nsError.userInfo[kPLKExitErrorRawJSONKey] as? String, "") + } + + // MARK: - API Error Tests + + func testApiErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .apiError(.internalServerError), + errorMessage: "Internal server error", + displayMesssage: "Something went wrong. Please try again.", + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorApiDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Internal server error") + XCTAssertEqual( + nsError.userInfo[kPLKExitErrorDisplayMessageKey] as? String, + "Something went wrong. Please try again." + ) + XCTAssertEqual( + nsError.userInfo[NSLocalizedDescriptionKey] as? String, + "Something went wrong. Please try again." + ) + XCTAssertNotEqual(nsError.code, -1) + } + + func testApiErrorPlannedMaintenanceToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .apiError(.plannedMaintenance), + errorMessage: "Planned maintenance", + displayMesssage: "We're currently performing maintenance. Please try again later.", + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorApiDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorCodeKey] as? String, "PLANNED_MAINTENANCE") + } + + // MARK: - Auth Error Tests + + func testAuthErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .authError(.productNotReady), + errorMessage: "Product not ready", + displayMesssage: "Auth product is not ready", + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorAuthDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Product not ready") + XCTAssertEqual(nsError.userInfo[kPLKExitErrorDisplayMessageKey] as? String, "Auth product is not ready") + XCTAssertNotEqual(nsError.code, -1) + } + + // MARK: - Asset Report Error Tests + + func testAssetReportErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .assetReportError(.productNotEnabled), + errorMessage: "Asset report product not enabled", + displayMesssage: "This feature is not enabled for your account", + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorAssetReportDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Asset report product not enabled") + XCTAssertNotEqual(nsError.code, -1) + } + + // MARK: - Internal Error Tests + + func testInternalErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .internal("INTERNAL_SDK_ERROR"), + errorMessage: "SDK encountered an internal error", + displayMesssage: "An unexpected error occurred", + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorInternalDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "SDK encountered an internal error") + XCTAssertEqual(nsError.userInfo[kPLKExitErrorCodeKey] as? String, "INTERNAL_SDK_ERROR") + XCTAssertEqual(nsError.code, -1) + } + + // MARK: - Institution Error Tests + + func testInstitutionErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .institutionError(.institutionDown), + errorMessage: "Institution is down", + displayMesssage: "The financial institution is currently unavailable", + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorInstitutionErrorDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Institution is down") + XCTAssertNotEqual(nsError.code, -1) + } + + // MARK: - Invalid Input Error Tests + + func testInvalidInputErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .invalidInput(.invalidAccountId), + errorMessage: "Invalid account number", + displayMesssage: "The account number you entered is invalid", + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorInvalidInputDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Invalid account number") + XCTAssertNotEqual(nsError.code, -1) + } + + // MARK: - Invalid Request Error Tests + + func testInvalidRequestErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .invalidRequest(.missingFields), + errorMessage: "Missing required fields", + displayMesssage: nil, + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorInvalidRequestDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Missing required fields") + XCTAssertNotEqual(nsError.code, -1) + } + + // MARK: - Rate Limit Error Tests + + func testRateLimitErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .rateLimitExceeded(.accountsLimit), + errorMessage: "Rate limit exceeded for accounts", + displayMesssage: "Too many requests. Please try again later.", + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorRateLimitExceededDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Rate limit exceeded for accounts") + XCTAssertNotEqual(nsError.code, -1) + } + + // MARK: - Unknown Error Tests + + func testUnknownErrorToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .unknown(type: "CUSTOM_ERROR_TYPE", code: "CUSTOM_ERROR_CODE"), + errorMessage: "Unknown error occurred", + displayMesssage: "An unknown error occurred", + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorUnknownDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Unknown error occurred") + XCTAssertEqual(nsError.userInfo[kPLKExitErrorCodeKey] as? String, "CUSTOM_ERROR_CODE") + XCTAssertEqual(nsError.userInfo[kPLKExitErrorUnknownTypeKey] as? String, "CUSTOM_ERROR_TYPE") + XCTAssertEqual(nsError.code, -1) + } + + // MARK: - Error with Full JSON Metadata Tests + + func testErrorWithCompleteMetadataToObjC() { + let jsonMetadata = """ + { + "error_code": "ITEM_LOGIN_REQUIRED", + "error_type": "ITEM_ERROR", + "error_message": "the login details of this item have changed", + "display_message": "The login details of this item have changed (credentials, MFA, or required user action). Please re-authenticate.", + "status": 400 + } + """ + + let exitError = ExitError.privateObjCInitializer( + errorCode: .itemError(.itemLoginRequired), + errorMessage: "the login details of this item have changed", + displayMesssage: + "The login details of this item have changed (credentials, MFA, or required user action). Please re-authenticate.", + errorJSON: jsonMetadata + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorItemDomain) + XCTAssertEqual( + nsError.userInfo[kPLKExitErrorMessageKey] as? String, + "the login details of this item have changed" + ) + XCTAssertEqual( + nsError.userInfo[kPLKExitErrorDisplayMessageKey] as? String, + "The login details of this item have changed (credentials, MFA, or required user action). Please re-authenticate." + ) + XCTAssertEqual( + nsError.userInfo[NSLocalizedDescriptionKey] as? String, + "The login details of this item have changed (credentials, MFA, or required user action). Please re-authenticate." + ) + XCTAssertNotNil(nsError.userInfo[kPLKExitErrorRawJSONKey]) + XCTAssertTrue((nsError.userInfo[kPLKExitErrorRawJSONKey] as? String)?.contains("ITEM_LOGIN_REQUIRED") ?? false) + } + + // MARK: - Error Code Conversion Tests + + func testItemErrorWithUnknownCodeToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .itemError(.unknown("CUSTOM_ITEM_ERROR")), + errorMessage: "Custom item error", + displayMesssage: nil, + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorItemDomain) + XCTAssertEqual(nsError.code, -1) // Unknown codes should return -1 + } + + func testApiErrorWithUnknownCodeToObjC() { + let exitError = ExitError.privateObjCInitializer( + errorCode: .apiError(.unknown("CUSTOM_API_ERROR")), + errorMessage: "Custom API error", + displayMesssage: nil, + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorApiDomain) + XCTAssertEqual(nsError.code, -1) + } + + // MARK: - Multiple Error Types Tests + + func testMultipleItemErrorsToObjC() { + let errorCodes: [ItemErrorCode] = [ + .itemLoginRequired, + .userSetupRequired, + .mfaNotSupported, + .noAccounts, + ] + + for errorCode in errorCodes { + let exitError = ExitError.privateObjCInitializer( + errorCode: .itemError(errorCode), + errorMessage: "Test error", + displayMesssage: nil, + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorItemDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Test error") + } + } + + func testMultipleInstitutionErrorsToObjC() { + let errorCodes: [InstitutionErrorCode] = [ + .institutionDown, + .institutionNotResponding, + .institutionNotAvailable, + .institutionNoLongerSupported, + ] + + for errorCode in errorCodes { + let exitError = ExitError.privateObjCInitializer( + errorCode: .institutionError(errorCode), + errorMessage: "Test error", + displayMesssage: nil, + errorJSON: nil + ) + + let nsError = exitError.toObjC + + XCTAssertEqual(nsError.domain, kPLKExitErrorInstitutionErrorDomain) + XCTAssertEqual(nsError.userInfo[kPLKExitErrorMessageKey] as? String, "Test error") + } + } +} diff --git a/Tests/LinkKitObjCTests/PLKExitMetadataTests.swift b/Tests/LinkKitObjCTests/PLKExitMetadataTests.swift new file mode 100644 index 0000000..17a7c61 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKExitMetadataTests.swift @@ -0,0 +1,42 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKExitMetadataTests: XCTestCase { + + func testExitMetadataWithAllFieldsToObjC() throws { + let jsonString = """ + { + "status": "requires_credentials", + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "link_session_id": "session_123", + "request_id": "req_123", + "metadata_json": "{\\"custom\\":\\"data\\"}" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let exitMetadata = try decoder.decode(ExitMetadata.self, from: data) + + XCTAssertEqual(exitMetadata.status, .requiresCredentials) + XCTAssertEqual(exitMetadata.institution?.id, "ins_123") + XCTAssertEqual(exitMetadata.institution?.name, "Test Bank") + XCTAssertEqual(exitMetadata.linkSessionID, "session_123") + XCTAssertEqual(exitMetadata.requestID, "req_123") + XCTAssertEqual(exitMetadata.metadataJSON, "{\"custom\":\"data\"}") + + let objcExitMetadata = exitMetadata.toObjC + + XCTAssertEqual(objcExitMetadata.status?.value, .requiresCredentials) + XCTAssertEqual(objcExitMetadata.institution?.id, "ins_123") + XCTAssertEqual(objcExitMetadata.institution?.name, "Test Bank") + XCTAssertEqual(objcExitMetadata.linkSessionID, "session_123") + XCTAssertEqual(objcExitMetadata.requestID, "req_123") + XCTAssertEqual(objcExitMetadata.metadataJSON, "{\"custom\":\"data\"}") + } +} diff --git a/Tests/LinkKitObjCTests/PLKExitStatusTests.swift b/Tests/LinkKitObjCTests/PLKExitStatusTests.swift new file mode 100644 index 0000000..a3d0d4f --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKExitStatusTests.swift @@ -0,0 +1,116 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKExitStatusTests: XCTestCase { + + // MARK: - Specific Conversion Tests + + func testRequiresQuestionsToObjC() { + let exitStatus = ExitStatus.requiresQuestions + let objcExitStatus = exitStatus.toObjC + XCTAssertEqual(objcExitStatus.value, .requiresQuestions) + } + + func testRequiresSelectionsToObjC() { + let exitStatus = ExitStatus.requiresSelections + let objcExitStatus = exitStatus.toObjC + XCTAssertEqual(objcExitStatus.value, .requiresSelections) + } + + func testRequiresCodeToObjC() { + let exitStatus = ExitStatus.requiresCode + let objcExitStatus = exitStatus.toObjC + XCTAssertEqual(objcExitStatus.value, .requiresCode) + } + + func testChooseDeviceToObjC() { + let exitStatus = ExitStatus.chooseDevice + let objcExitStatus = exitStatus.toObjC + XCTAssertEqual(objcExitStatus.value, .chooseDevice) + } + + func testRequiresCredentialsToObjC() { + let exitStatus = ExitStatus.requiresCredentials + let objcExitStatus = exitStatus.toObjC + XCTAssertEqual(objcExitStatus.value, .requiresCredentials) + } + + func testInstitutionNotFoundToObjC() { + let exitStatus = ExitStatus.institutionNotFound + let objcExitStatus = exitStatus.toObjC + XCTAssertEqual(objcExitStatus.value, .institutionNotFound) + } + + func testRequiresAccountSelectionToObjC() { + let exitStatus = ExitStatus.requiresAccountSelection + let objcExitStatus = exitStatus.toObjC + XCTAssertEqual(objcExitStatus.value, .requiresAccountSelection) + } + + func testContinueToThirdPartyToObjC() { + let exitStatus = ExitStatus.continueToThirdParty + let objcExitStatus = exitStatus.toObjC + // NOTE: There's a typo in the ObjC enum: "continueToThridParty" (missing 'i') + XCTAssertEqual(objcExitStatus.value, .continueToThridParty) + } + + func testUnknownToObjC() { + let exitStatus = ExitStatus.unknown("custom_exit_status") + let objcExitStatus = exitStatus.toObjC + XCTAssertEqual(objcExitStatus.value, .none) + XCTAssertEqual(objcExitStatus.unknownStringValue, "custom_exit_status") + } + + func testAllKnownExitStatusesMapToValidObjCValues() { + // Test all known exit status cases convert successfully + let knownStatuses: [ExitStatus] = [ + .requiresQuestions, + .requiresSelections, + .requiresCode, + .chooseDevice, + .requiresCredentials, + .institutionNotFound, + .requiresAccountSelection, + .continueToThirdParty, + ] + + for exitStatus in knownStatuses { + let objcExitStatus = exitStatus.toObjC + XCTAssertNotEqual( + objcExitStatus.value, + .none, + "Status '\(exitStatus.description)' should have a valid ObjC mapping" + ) + XCTAssertNil( + objcExitStatus.unknownStringValue, + "Known status '\(exitStatus.description)' should not have unknownStringValue" + ) + } + } + + func testOneToOneMappingBetweenSwiftAndObjC() { + // Verify that all 8 known Swift cases map to distinct ObjC values + let mappings: [(ExitStatus, PLKExitStatusValue)] = [ + (.requiresQuestions, .requiresQuestions), + (.requiresSelections, .requiresSelections), + (.requiresCode, .requiresCode), + (.chooseDevice, .chooseDevice), + (.requiresCredentials, .requiresCredentials), + (.institutionNotFound, .institutionNotFound), + (.requiresAccountSelection, .requiresAccountSelection), + (.continueToThirdParty, .continueToThridParty), + ] + + for (swiftStatus, expectedObjCValue) in mappings { + let objcStatus = swiftStatus.toObjC + XCTAssertEqual( + objcStatus.value, + expectedObjCValue, + "Status '\(swiftStatus.description)' should map to '\(expectedObjCValue.rawValue)'" + ) + } + } +} diff --git a/Tests/LinkKitObjCTests/PLKInstitutionErrorCodeTests.swift b/Tests/LinkKitObjCTests/PLKInstitutionErrorCodeTests.swift new file mode 100644 index 0000000..acd0180 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKInstitutionErrorCodeTests.swift @@ -0,0 +1,37 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKInstitutionErrorCodeTests: XCTestCase { + + func testInstitutionDownToObjC() { + let errorCode = InstitutionErrorCode.institutionDown + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .institutionDown) + } + + func testInstitutionNotRespondingToObjC() { + let errorCode = InstitutionErrorCode.institutionNotResponding + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .institutionNotResponding) + } + + func testInstitutionNotAvailableToObjC() { + let errorCode = InstitutionErrorCode.institutionNotAvailable + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .institutionNotAvailable) + } + + func testInstitutionNoLongerSupportedToObjC() { + let errorCode = InstitutionErrorCode.institutionNoLongerSupported + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .institutionNoLongerSupported) + } + + func testUnknownToObjC() { + let errorCode = InstitutionErrorCode.unknown("CUSTOM_INSTITUTION_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } +} diff --git a/Tests/LinkKitObjCTests/PLKInstitutionTests.swift b/Tests/LinkKitObjCTests/PLKInstitutionTests.swift new file mode 100644 index 0000000..32f6046 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKInstitutionTests.swift @@ -0,0 +1,25 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKInstitutionTests: XCTestCase { + + func testInstitutionToObjC() throws { + let id = "ins_123456" + let name = "Plaid Bank" + + let json = """ + { + "id": "\(id)", + "name": "\(name)" + } + """ + + let institution = try JSONDecoder().decode(Institution.self, from: json.data(using: .utf8)!) + let objcInstitution = institution.toObjC + + XCTAssertEqual(objcInstitution.id, "ins_123456") + XCTAssertEqual(objcInstitution.name, "Plaid Bank") + } +} diff --git a/Tests/LinkKitObjCTests/PLKInvalidInputErrorCodeTests.swift b/Tests/LinkKitObjCTests/PLKInvalidInputErrorCodeTests.swift new file mode 100644 index 0000000..6f06cd4 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKInvalidInputErrorCodeTests.swift @@ -0,0 +1,79 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKInvalidInputErrorCodeTests: XCTestCase { + + func testInvalidApiKeysToObjC() { + let errorCode = InvalidInputErrorCode.invalidApiKeys + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidApiKeys) + } + + func testUnauthorizedEnvironmentToObjC() { + let errorCode = InvalidInputErrorCode.unauthorizedEnvironment + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .unauthorizedEnvironment) + } + + func testInvalidAccessTokenToObjC() { + let errorCode = InvalidInputErrorCode.invalidAccessToken + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidAccessToken) + } + + func testInvalidPublicTokenToObjC() { + let errorCode = InvalidInputErrorCode.invalidPublicToken + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidPublicToken) + } + + func testInvalidProductToObjC() { + let errorCode = InvalidInputErrorCode.invalidProduct + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidProduct) + } + + func testInvalidAccountIdToObjC() { + let errorCode = InvalidInputErrorCode.invalidAccountId + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidAccountId) + } + + func testInvalidInstitutionToObjC() { + let errorCode = InvalidInputErrorCode.invalidInstitution + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidInstitution) + } + + func testTooManyVerificationAttemptsToObjC() { + let errorCode = InvalidInputErrorCode.tooManyVerificationAttempts + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .tooManyVerificationAttempts) + } + + func testUnknownToObjC() { + let errorCode = InvalidInputErrorCode.unknown("CUSTOM_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } + + func testAllKnownErrorCodesMapToObjC() { + let knownErrorCodes: [InvalidInputErrorCode] = [ + .invalidApiKeys, + .unauthorizedEnvironment, + .invalidAccessToken, + .invalidPublicToken, + .invalidProduct, + .invalidAccountId, + .invalidInstitution, + .tooManyVerificationAttempts, + ] + + for errorCode in knownErrorCodes { + let objcErrorCode = errorCode.toObjC + XCTAssertNotNil(objcErrorCode, "Error code '\(errorCode.description)' should have a valid ObjC mapping") + } + } +} diff --git a/Tests/LinkKitObjCTests/PLKInvalidRequestErrorCodeTests.swift b/Tests/LinkKitObjCTests/PLKInvalidRequestErrorCodeTests.swift new file mode 100644 index 0000000..4b0ba7f --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKInvalidRequestErrorCodeTests.swift @@ -0,0 +1,55 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKInvalidRequestErrorCodeTests: XCTestCase { + + func testMissingFieldsToObjC() { + let errorCode = InvalidRequestErrorCode.missingFields + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .missingFields) + } + + func testUnknownFieldsToObjC() { + let errorCode = InvalidRequestErrorCode.unknownFields + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .unknownFields) + } + + func testInvalidFieldToObjC() { + let errorCode = InvalidRequestErrorCode.invalidField + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidField) + } + + func testInvalidBodyToObjC() { + let errorCode = InvalidRequestErrorCode.invalidBody + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidBody) + } + + func testInvalidAddressToObjC() { + let errorCode = InvalidRequestErrorCode.invalidAddress + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidAddress) + } + + func testNotFoundToObjC() { + let errorCode = InvalidRequestErrorCode.notFound + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .notFound) + } + + func testSandboxOnlyToObjC() { + let errorCode = InvalidRequestErrorCode.sandboxOnly + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .sandboxOnly) + } + + func testUnknownToObjC() { + let errorCode = InvalidRequestErrorCode.unknown("CUSTOM_INVALID_REQUEST_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } +} diff --git a/Tests/LinkKitObjCTests/PLKItemErrorCodeTests.swift b/Tests/LinkKitObjCTests/PLKItemErrorCodeTests.swift new file mode 100644 index 0000000..e9778c9 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKItemErrorCodeTests.swift @@ -0,0 +1,156 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKItemErrorCodeTests: XCTestCase { + + func testInsufficientCredentialsToObjC() { + let errorCode = ItemErrorCode.insufficientCredentials + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .insufficientCredentials) + } + + func testInvalidCredentialsToObjC() { + let errorCode = ItemErrorCode.invalidCredentials + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidCredentials) + } + + func testInvalidMfaToObjC() { + let errorCode = ItemErrorCode.invalidMfa + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidMfa) + } + + func testInvalidSendMethodToObjC() { + let errorCode = ItemErrorCode.invalidSendMethod + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidSendMethod) + } + + func testInvalidUpdatedUsernameToObjC() { + let errorCode = ItemErrorCode.invalidUpdatedUsername + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .invalidUpdatedUsername) + } + + func testItemLockedToObjC() { + let errorCode = ItemErrorCode.itemLocked + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .itemLocked) + } + + func testItemLoginRequiredToObjC() { + let errorCode = ItemErrorCode.itemLoginRequired + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .itemLoginRequired) + } + + func testItemNoErrorToObjC() { + let errorCode = ItemErrorCode.itemNoError + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .itemNoError) + } + + func testItemNotSupportedToObjC() { + let errorCode = ItemErrorCode.itemNotSupported + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .itemNotSupported) + } + + func testIncorrectDepositAmountsToObjC() { + let errorCode = ItemErrorCode.incorrectDepositAmounts + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .incorrectDepositAmounts) + } + + func testUserSetupRequiredToObjC() { + let errorCode = ItemErrorCode.userSetupRequired + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .userSetupRequired) + } + + func testMfaNotSupportedToObjC() { + let errorCode = ItemErrorCode.mfaNotSupported + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .mfaNotSupported) + } + + func testNoAccountsToObjC() { + let errorCode = ItemErrorCode.noAccounts + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .noAccounts) + } + + func testNoAuthAccountsToObjC() { + let errorCode = ItemErrorCode.noAuthAccounts + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .noAuthAccounts) + } + + func testNoInvestmentAccountsToObjC() { + let errorCode = ItemErrorCode.noInvestmentAccounts + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .noInvestmentAccounts) + } + + func testNoLiabilityAccountsToObjC() { + let errorCode = ItemErrorCode.noLiabilityAccounts + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .noLiabilityAccounts) + } + + func testProductNotReadyToObjC() { + let errorCode = ItemErrorCode.productNotReady + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .productNotReady) + } + + func testProductsNotSupportedToObjC() { + let errorCode = ItemErrorCode.productsNotSupported + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .productsNotSupported) + } + + func testInstantMatchFailedToObjC() { + let errorCode = ItemErrorCode.instantMatchFailed + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .instantMatchFailed) + } + + func testUnknownToObjC() { + let errorCode = ItemErrorCode.unknown("CUSTOM_ITEM_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } + + func testAllKnownErrorCodesMapToObjC() { + let knownErrorCodes: [ItemErrorCode] = [ + .insufficientCredentials, + .invalidCredentials, + .invalidMfa, + .invalidSendMethod, + .invalidUpdatedUsername, + .itemLocked, + .itemLoginRequired, + .itemNoError, + .itemNotSupported, + .incorrectDepositAmounts, + .userSetupRequired, + .mfaNotSupported, + .noAccounts, + .noAuthAccounts, + .noInvestmentAccounts, + .noLiabilityAccounts, + .productNotReady, + .productsNotSupported, + .instantMatchFailed, + ] + + for errorCode in knownErrorCodes { + let objcErrorCode = errorCode.toObjC + XCTAssertNotNil(objcErrorCode, "Error code '\(errorCode.description)' should have a valid ObjC mapping") + } + } +} diff --git a/Tests/LinkKitObjCTests/PLKLayerTokenConfigurationTests.swift b/Tests/LinkKitObjCTests/PLKLayerTokenConfigurationTests.swift new file mode 100644 index 0000000..e066970 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKLayerTokenConfigurationTests.swift @@ -0,0 +1,166 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKLayerTokenConfigurationTests: XCTestCase { + + func testTokenIsPassedThrough() { + let config = PLKLayerTokenConfiguration( + token: "test-token-123", + onSuccess: { _ in } + ) + + let swiftConfig = config.toSwift + XCTAssertEqual(swiftConfig.token, "test-token-123") + } + + func testOnSuccessHandlerIsCalled() throws { + var capturedObjCSuccess: PLKLinkSuccess? + let config = PLKLayerTokenConfiguration( + token: "test-token", + onSuccess: { success in + capturedObjCSuccess = success + } + ) + + let swiftConfig = config.toSwift + + // Create Swift LinkSuccess from JSON + let jsonString = """ + { + "public_token": "public-token-123", + "metadata": { + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "accounts": [ + { + "id": "acc_1", + "name": "Checking", + "mask": "1234", + "subtype": "depository.checking" + } + ], + "link_session_id": "session_123" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkSuccess = try decoder.decode(LinkSuccess.self, from: data) + + // Call the Swift onSuccess handler + swiftConfig.onSuccess(linkSuccess) + + // Verify ObjC handler was called with converted data + XCTAssertNotNil(capturedObjCSuccess) + XCTAssertEqual(capturedObjCSuccess?.publicToken, "public-token-123") + XCTAssertEqual(capturedObjCSuccess?.metadata.linkSessionID, "session_123") + XCTAssertEqual(capturedObjCSuccess?.metadata.institution.id, "ins_123") + XCTAssertEqual(capturedObjCSuccess?.metadata.institution.name, "Test Bank") + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.count, 1) + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.first?.id, "acc_1") + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.first?.name, "Checking") + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.first?.mask, "1234") + } + + func testOnExitHandlerIsCalled() throws { + var capturedObjCExit: PLKLinkExit? + let config = PLKLayerTokenConfiguration( + token: "test-token", + onSuccess: { _ in } + ) + config.onExit = { exit in + capturedObjCExit = exit + } + + let swiftConfig = config.toSwift + + // Create Swift LinkExit from JSON + let jsonString = """ + { + "error": { + "errorCode": { + "type": "ITEM_ERROR", + "code": "ITEM_LOGIN_REQUIRED" + }, + "errorMessage": "Item login required", + "displayMessage": "Please log in again" + }, + "metadata": { + "status": "requires_credentials", + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "link_session_id": "session_123", + "request_id": "req_123" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + // Call the Swift onExit handler + swiftConfig.onExit?(linkExit) + + // Verify ObjC handler was called with converted data + XCTAssertNotNil(capturedObjCExit) + XCTAssertEqual(capturedObjCExit?.metadata.linkSessionID, "session_123") + XCTAssertEqual(capturedObjCExit?.metadata.requestID, "req_123") + XCTAssertEqual(capturedObjCExit?.metadata.institution?.id, "ins_123") + XCTAssertEqual(capturedObjCExit?.metadata.institution?.name, "Test Bank") + XCTAssertNotNil(capturedObjCExit?.error) + XCTAssertEqual(capturedObjCExit?.error?.localizedDescription, "Please log in again") + } + + func testOnEventHandlerIsCalled() throws { + var capturedObjCEvent: PLKLinkEvent? + let config = PLKLayerTokenConfiguration( + token: "test-token", + onSuccess: { _ in } + ) + + config.onEvent = { event in + capturedObjCEvent = event + } + + let swiftConfig = config.toSwift + + // Create Swift LinkEvent from JSON + let jsonString = """ + { + "event_name": "OPEN", + "metadata": { + "link_session_id": "session_123", + "request_id": "req_123", + "institution_id": "ins_123", + "institution_name": "Test Bank", + "view_name": "SELECT_INSTITUTION", + "timestamp": "2024-01-21T12:00:00.000Z" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkEvent = try decoder.decode(LinkEvent.self, from: data) + + // Call the Swift onEvent handler + swiftConfig.onEvent?(linkEvent) + + // Verify ObjC handler was called with converted data + XCTAssertNotNil(capturedObjCEvent) + XCTAssertEqual(capturedObjCEvent?.eventName.value, .open) + XCTAssertEqual(capturedObjCEvent?.eventMetadata.linkSessionID, "session_123") + XCTAssertEqual(capturedObjCEvent?.eventMetadata.requestID, "req_123") + XCTAssertEqual(capturedObjCEvent?.eventMetadata.institutionID, "ins_123") + XCTAssertEqual(capturedObjCEvent?.eventMetadata.institutionName, "Test Bank") + } +} diff --git a/Tests/LinkKitObjCTests/PLKLinkEventTests.swift b/Tests/LinkKitObjCTests/PLKLinkEventTests.swift new file mode 100644 index 0000000..1718de0 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKLinkEventTests.swift @@ -0,0 +1,134 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKLinkEventTests: XCTestCase { + + func testLinkEventToObjCWithOpenEvent() throws { + let jsonString = """ + { + "event_name": "OPEN", + "metadata": { + "link_session_id": "session_123", + "timestamp": "2024-01-21T12:00:00.000Z" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkEvent = try decoder.decode(LinkEvent.self, from: data) + + let objcLinkEvent = linkEvent.toObjC + + XCTAssertEqual(objcLinkEvent.eventName.value, .open) + XCTAssertEqual(objcLinkEvent.eventMetadata.linkSessionID, "session_123") + } + + func testLinkEventToObjCWithSelectInstitutionEvent() throws { + let jsonString = """ + { + "event_name": "SELECT_INSTITUTION", + "metadata": { + "link_session_id": "session_456", + "institution_id": "ins_123", + "institution_name": "Test Bank", + "timestamp": "2024-01-21T12:05:00.000Z" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkEvent = try decoder.decode(LinkEvent.self, from: data) + + let objcLinkEvent = linkEvent.toObjC + + XCTAssertEqual(objcLinkEvent.eventName.value, .selectInstitution) + XCTAssertEqual(objcLinkEvent.eventMetadata.linkSessionID, "session_456") + XCTAssertEqual(objcLinkEvent.eventMetadata.institutionID, "ins_123") + XCTAssertEqual(objcLinkEvent.eventMetadata.institutionName, "Test Bank") + } + + func testLinkEventToObjCWithErrorEvent() throws { + let jsonString = """ + { + "event_name": "ERROR", + "metadata": { + "link_session_id": "session_789", + "error_code": { + "type": "ITEM_ERROR", + "code": "ITEM_LOGIN_REQUIRED" + }, + "error_message": "Item login required", + "timestamp": "2024-01-21T12:10:00.000Z" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkEvent = try decoder.decode(LinkEvent.self, from: data) + + let objcLinkEvent = linkEvent.toObjC + + XCTAssertEqual(objcLinkEvent.eventName.value, .error) + XCTAssertEqual(objcLinkEvent.eventMetadata.linkSessionID, "session_789") + XCTAssertNotNil(objcLinkEvent.eventMetadata.error) + } + + func testLinkEventToObjCWithSubmitCredentialsEvent() throws { + let jsonString = """ + { + "event_name": "SUBMIT_CREDENTIALS", + "metadata": { + "link_session_id": "session_999", + "institution_id": "ins_999", + "institution_name": "Another Bank", + "timestamp": "2024-01-21T12:15:00.000Z" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkEvent = try decoder.decode(LinkEvent.self, from: data) + + let objcLinkEvent = linkEvent.toObjC + + XCTAssertEqual(objcLinkEvent.eventName.value, .submitCredentials) + XCTAssertEqual(objcLinkEvent.eventMetadata.linkSessionID, "session_999") + XCTAssertEqual(objcLinkEvent.eventMetadata.institutionID, "ins_999") + XCTAssertEqual(objcLinkEvent.eventMetadata.institutionName, "Another Bank") + } + + func testLinkEventToObjCPreservesAllMetadata() throws { + let jsonString = """ + { + "event_name": "TRANSITION_VIEW", + "metadata": { + "link_session_id": "session_complete", + "request_id": "req_123", + "institution_id": "ins_final", + "institution_name": "Final Bank", + "view_name": "CONNECTED", + "timestamp": "2024-01-21T12:20:00.000Z" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkEvent = try decoder.decode(LinkEvent.self, from: data) + + let objcLinkEvent = linkEvent.toObjC + + XCTAssertEqual(objcLinkEvent.eventName.value, .transitionView) + XCTAssertEqual(objcLinkEvent.eventMetadata.linkSessionID, "session_complete") + XCTAssertEqual(objcLinkEvent.eventMetadata.requestID, "req_123") + XCTAssertEqual(objcLinkEvent.eventMetadata.institutionID, "ins_final") + XCTAssertEqual(objcLinkEvent.eventMetadata.institutionName, "Final Bank") + XCTAssertNotNil(objcLinkEvent.eventMetadata.viewName) + } +} diff --git a/Tests/LinkKitObjCTests/PLKLinkExitTests.swift b/Tests/LinkKitObjCTests/PLKLinkExitTests.swift new file mode 100644 index 0000000..07c341c --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKLinkExitTests.swift @@ -0,0 +1,301 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKLinkExitTests: XCTestCase { + + func testLinkExitWithErrorAndMetadataToObjC() throws { + let jsonString = """ + { + "error": { + "errorCode": { + "type": "ITEM_ERROR", + "code": "ITEM_LOGIN_REQUIRED" + }, + "errorMessage": "Item login required", + "displayMessage": "Please log in again" + }, + "metadata": { + "status": "requires_credentials", + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "link_session_id": "session_123", + "request_id": "req_123" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + XCTAssertNotNil(linkExit.error) + XCTAssertEqual(linkExit.error?.errorMessage, "Item login required") + XCTAssertEqual(linkExit.metadata.linkSessionID, "session_123") + XCTAssertEqual(linkExit.metadata.requestID, "req_123") + XCTAssertEqual(linkExit.metadata.institution?.id, "ins_123") + XCTAssertEqual(linkExit.metadata.institution?.name, "Test Bank") + XCTAssertEqual(linkExit.metadata.status, .requiresCredentials) + + let objcLinkExit = linkExit.toObjC + + XCTAssertNotNil(objcLinkExit.error) + + let nsError = objcLinkExit.error as? NSError + + XCTAssertEqual(nsError?.domain, kPLKExitErrorItemDomain) + XCTAssertEqual(nsError?.userInfo[kPLKExitErrorMessageKey] as? String, "Item login required") + XCTAssertEqual(nsError?.localizedDescription, "Please log in again") + XCTAssertEqual(objcLinkExit.metadata.linkSessionID, "session_123") + XCTAssertEqual(objcLinkExit.metadata.requestID, "req_123") + XCTAssertEqual(objcLinkExit.metadata.institution?.id, "ins_123") + XCTAssertEqual(objcLinkExit.metadata.institution?.name, "Test Bank") + XCTAssertEqual(objcLinkExit.metadata.status?.value, .requiresCredentials) + } + + func testLinkExitWithoutErrorToObjC() throws { + let jsonString = """ + { + "metadata": { + "link_session_id": "session_no_error", + "request_id": "req_no_error" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + XCTAssertNil(linkExit.error) + XCTAssertEqual(linkExit.metadata.linkSessionID, "session_no_error") + XCTAssertEqual(linkExit.metadata.requestID, "req_no_error") + + let objcLinkExit = linkExit.toObjC + + XCTAssertNil(objcLinkExit.error) + XCTAssertEqual(objcLinkExit.metadata.linkSessionID, "session_no_error") + XCTAssertEqual(objcLinkExit.metadata.requestID, "req_no_error") + } + + func testLinkExitWithApiErrorToObjC() throws { + let jsonString = """ + { + "error": { + "errorCode": { + "type": "API_ERROR", + "code": "INTERNAL_SERVER_ERROR" + }, + "errorMessage": "An internal server error occurred", + "displayMessage": "Something went wrong. Please try again." + }, + "metadata": { + "link_session_id": "session_api_error", + "request_id": "req_api_error" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + XCTAssertNotNil(linkExit.error) + XCTAssertEqual(linkExit.error?.errorMessage, "An internal server error occurred") + + let objcLinkExit = linkExit.toObjC + + XCTAssertNotNil(objcLinkExit.error) + let nsError = objcLinkExit.error as? NSError + + XCTAssertEqual(nsError?.domain, kPLKExitErrorApiDomain) + XCTAssertEqual( + nsError?.userInfo[kPLKExitErrorMessageKey] as? String, + "An internal server error occurred" + ) + XCTAssertEqual(objcLinkExit.error?.localizedDescription, "Something went wrong. Please try again.") + } + + func testLinkExitWithInstitutionErrorToObjC() throws { + let jsonString = """ + { + "error": { + "errorCode": { + "type": "INSTITUTION_ERROR", + "code": "INSTITUTION_DOWN" + }, + "errorMessage": "The institution is down", + "displayMessage": "The financial institution is currently unavailable" + }, + "metadata": { + "status": "institution_not_found", + "institution": { + "id": "ins_down", + "name": "Down Bank" + }, + "link_session_id": "session_inst_error", + "request_id": "req_inst_error" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + XCTAssertNotNil(linkExit.error) + XCTAssertEqual(linkExit.metadata.institution?.name, "Down Bank") + + let objcLinkExit = linkExit.toObjC + + XCTAssertNotNil(objcLinkExit.error) + let nsError = objcLinkExit.error as? NSError + + XCTAssertEqual(nsError?.domain, kPLKExitErrorInstitutionErrorDomain) + XCTAssertEqual(objcLinkExit.metadata.institution?.name, "Down Bank") + XCTAssertEqual(objcLinkExit.metadata.status?.value, .institutionNotFound) + } + + func testLinkExitWithMetadataOnlyToObjC() throws { + let jsonString = """ + { + "metadata": { + "status": "requires_questions", + "institution": { + "id": "ins_456", + "name": "Question Bank" + }, + "link_session_id": "session_metadata", + "request_id": "req_metadata" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + XCTAssertNil(linkExit.error) + XCTAssertEqual(linkExit.metadata.status, .requiresQuestions) + XCTAssertEqual(linkExit.metadata.institution?.id, "ins_456") + XCTAssertEqual(linkExit.metadata.institution?.name, "Question Bank") + + let objcLinkExit = linkExit.toObjC + + XCTAssertNil(objcLinkExit.error) + XCTAssertEqual(objcLinkExit.metadata.status?.value, .requiresQuestions) + XCTAssertEqual(objcLinkExit.metadata.institution?.id, "ins_456") + XCTAssertEqual(objcLinkExit.metadata.institution?.name, "Question Bank") + XCTAssertEqual(objcLinkExit.metadata.linkSessionID, "session_metadata") + XCTAssertEqual(objcLinkExit.metadata.requestID, "req_metadata") + } + + func testLinkExitWithInternalErrorToObjC() throws { + let jsonString = """ + { + "error": { + "errorCode": { + "type": "INTERNAL", + "code": "SDK_ERROR" + }, + "errorMessage": "SDK encountered an error", + "displayMessage": "An unexpected error occurred" + }, + "metadata": { + "link_session_id": "session_internal" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + XCTAssertNotNil(linkExit.error) + + let objcLinkExit = linkExit.toObjC + + XCTAssertNotNil(objcLinkExit.error) + let nsError = objcLinkExit.error as? NSError + + XCTAssertEqual(nsError?.domain, kPLKExitErrorInternalDomain) + XCTAssertEqual(nsError?.code, -1) + XCTAssertEqual(objcLinkExit.metadata.linkSessionID, "session_internal") + } + + func testLinkExitWithUnknownErrorToObjC() throws { + let jsonString = """ + { + "error": { + "errorCode": { + "type": "CUSTOM_ERROR_TYPE", + "code": "CUSTOM_ERROR_CODE" + }, + "errorMessage": "A custom error occurred", + "displayMessage": "Custom error message" + }, + "metadata": { + "link_session_id": "session_unknown" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + XCTAssertNotNil(linkExit.error) + + let objcLinkExit = linkExit.toObjC + + XCTAssertNotNil(objcLinkExit.error) + + let nsError = objcLinkExit.error as? NSError + XCTAssertEqual(nsError?.domain, kPLKExitErrorUnknownDomain) + XCTAssertEqual(nsError?.code, -1) + XCTAssertEqual(nsError?.userInfo[kPLKExitErrorUnknownTypeKey] as? String, "CUSTOM_ERROR_TYPE") + XCTAssertEqual(nsError?.userInfo[kPLKExitErrorCodeKey] as? String, "CUSTOM_ERROR_CODE") + } + + func testMultipleLinkExitConversions() throws { + let exitScenarios = [ + """ + { + "error": {"errorCode": {"type": "ITEM_ERROR", "code": "ITEM_LOGIN_REQUIRED"}, "errorMessage": "Login required"}, + "metadata": {"link_session_id": "session_1"} + } + """, + """ + { + "metadata": {"link_session_id": "session_2"} + } + """, + """ + { + "error": {"errorCode": {"type": "API_ERROR", "code": "PLANNED_MAINTENANCE"}, "errorMessage": "Maintenance"}, + "metadata": {"link_session_id": "session_3"} + } + """, + ] + + let decoder = JSONDecoder() + for (index, jsonString) in exitScenarios.enumerated() { + let data = jsonString.data(using: .utf8)! + let linkExit = try decoder.decode(LinkExit.self, from: data) + let objcLinkExit = linkExit.toObjC + + XCTAssertEqual(objcLinkExit.metadata.linkSessionID, "session_\(index + 1)") + + // First and third scenarios have errors + if index == 0 || index == 2 { + XCTAssertNotNil(objcLinkExit.error) + } else { + XCTAssertNil(objcLinkExit.error) + } + } + } +} diff --git a/Tests/LinkKitObjCTests/PLKLinkSuccessTests.swift b/Tests/LinkKitObjCTests/PLKLinkSuccessTests.swift new file mode 100644 index 0000000..73293c2 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKLinkSuccessTests.swift @@ -0,0 +1,213 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKLinkSuccessTests: XCTestCase { + + func testLinkSuccessWithSingleAccountToObjC() throws { + let jsonString = """ + { + "public_token": "public-token-abc123", + "metadata": { + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "accounts": [{ + "id": "acc_123", + "name": "Checking", + "mask": "0000", + "subtype": "depository.checking", + "verificationStatus": "manually_verified" + }], + "link_session_id": "link_session_123" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkSuccess = try decoder.decode(LinkSuccess.self, from: data) + + let objcLinkSuccess = linkSuccess.toObjC + + XCTAssertEqual(objcLinkSuccess.publicToken, "public-token-abc123") + XCTAssertEqual(objcLinkSuccess.metadata.linkSessionID, "link_session_123") + XCTAssertEqual(objcLinkSuccess.metadata.institution.id, "ins_123") + XCTAssertEqual(objcLinkSuccess.metadata.institution.name, "Test Bank") + XCTAssertEqual(objcLinkSuccess.metadata.accounts.count, 1) + XCTAssertEqual(objcLinkSuccess.metadata.accounts[0].id, "acc_123") + XCTAssertEqual(objcLinkSuccess.metadata.accounts[0].name, "Checking") + XCTAssertEqual(objcLinkSuccess.metadata.accounts[0].mask, "0000") + XCTAssertNil(objcLinkSuccess.metadata.metadataJSON) + } + + func testLinkSuccessWithMultipleAccountsToObjC() throws { + let jsonString = """ + { + "public_token": "public-token-xyz789", + "metadata": { + "institution": { + "id": "ins_456", + "name": "Another Bank" + }, + "accounts": [ + { + "id": "acc_1", + "name": "Checking", + "mask": "0001", + "subtype": "depository.checking", + "verificationStatus": "manually_verified" + }, + { + "id": "acc_2", + "name": "Savings", + "mask": "0002", + "subtype": "depository.savings", + "verificationStatus": "pending_automatic_verification" + }, + { + "id": "acc_3", + "name": "Credit Card", + "mask": "1234", + "subtype": "credit.credit card" + } + ], + "link_session_id": "link_session_456", + "metadata_json": "{\\"custom\\":\\"data\\"}" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkSuccess = try decoder.decode(LinkSuccess.self, from: data) + + let objcLinkSuccess = linkSuccess.toObjC + + XCTAssertEqual(objcLinkSuccess.publicToken, "public-token-xyz789") + XCTAssertEqual(objcLinkSuccess.metadata.linkSessionID, "link_session_456") + XCTAssertEqual(objcLinkSuccess.metadata.institution.id, "ins_456") + XCTAssertEqual(objcLinkSuccess.metadata.institution.name, "Another Bank") + XCTAssertEqual(objcLinkSuccess.metadata.accounts.count, 3) + + XCTAssertEqual(objcLinkSuccess.metadata.accounts[0].id, "acc_1") + XCTAssertEqual(objcLinkSuccess.metadata.accounts[0].name, "Checking") + XCTAssertEqual(objcLinkSuccess.metadata.accounts[0].mask, "0001") + + XCTAssertEqual(objcLinkSuccess.metadata.accounts[1].id, "acc_2") + XCTAssertEqual(objcLinkSuccess.metadata.accounts[1].name, "Savings") + XCTAssertEqual(objcLinkSuccess.metadata.accounts[1].mask, "0002") + + XCTAssertEqual(objcLinkSuccess.metadata.accounts[2].id, "acc_3") + XCTAssertEqual(objcLinkSuccess.metadata.accounts[2].name, "Credit Card") + XCTAssertEqual(objcLinkSuccess.metadata.accounts[2].mask, "1234") + + XCTAssertEqual(objcLinkSuccess.metadata.metadataJSON, "{\"custom\":\"data\"}") + } + + func testLinkSuccessWithMetadataJSONToObjC() throws { + let jsonString = """ + { + "public_token": "public-token-json", + "metadata": { + "institution": { + "id": "ins_789", + "name": "JSON Bank" + }, + "accounts": [{ + "id": "acc_json", + "name": "Account", + "subtype": "investment.ira" + }], + "link_session_id": "link_session_json", + "metadata_json": "{\\"key\\":\\"value\\",\\"nested\\":{\\"field\\":123}}" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkSuccess = try decoder.decode(LinkSuccess.self, from: data) + + let objcLinkSuccess = linkSuccess.toObjC + + XCTAssertEqual(objcLinkSuccess.publicToken, "public-token-json") + XCTAssertEqual(objcLinkSuccess.metadata.metadataJSON, "{\"key\":\"value\",\"nested\":{\"field\":123}}") + } + + func testLinkSuccessPreservesPublicToken() throws { + let tokens = [ + "public-sandbox-abc123", + "public-development-xyz789", + "public-production-def456", + ] + + for token in tokens { + let jsonString = """ + { + "public_token": "\(token)", + "metadata": { + "institution": { + "id": "ins_test", + "name": "Test" + }, + "accounts": [{ + "id": "acc_test", + "name": "Test Account", + "subtype": "other.other" + }], + "link_session_id": "session_test" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkSuccess = try decoder.decode(LinkSuccess.self, from: data) + + let objcLinkSuccess = linkSuccess.toObjC + + XCTAssertEqual(objcLinkSuccess.publicToken, token) + } + } + + func testLinkSuccessAccountSubtypesConvertCorrectly() throws { + let testCases: [(String, String)] = [ + ("depository.checking", "checking"), + ("depository.savings", "savings"), + ("credit.credit card", "credit card"), + ("loan.mortgage", "mortgage"), + ("investment.brokerage", "brokerage"), + ] + + for (subtypeJSON, expectedRawValue) in testCases { + let jsonString = """ + { + "public_token": "token_test", + "metadata": { + "institution": { + "id": "ins_test", + "name": "Test Bank" + }, + "accounts": [{ + "id": "acc_test", + "name": "Test Account", + "subtype": "\(subtypeJSON)" + }], + "link_session_id": "session_test" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkSuccess = try decoder.decode(LinkSuccess.self, from: data) + + let objcLinkSuccess = linkSuccess.toObjC + + XCTAssertEqual(objcLinkSuccess.metadata.accounts[0].subtype.rawStringValue, expectedRawValue) + } + } +} diff --git a/Tests/LinkKitObjCTests/PLKLinkTokenConfigurationTests.swift b/Tests/LinkKitObjCTests/PLKLinkTokenConfigurationTests.swift new file mode 100644 index 0000000..cf51d54 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKLinkTokenConfigurationTests.swift @@ -0,0 +1,275 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKLinkTokenConfigurationTests: XCTestCase { + + func testTokenIsPassedThrough() { + let config = PLKLinkTokenConfiguration( + token: "test-token-123", + onSuccess: { _ in } + ) + config.onExit = { _ in } + config.onEvent = { _ in } + + let swiftConfig = config.toSwift + XCTAssertEqual(swiftConfig.token, "test-token-123") + } + + func testOnSuccessHandlerIsCalled() throws { + var capturedObjCSuccess: PLKLinkSuccess? + let config = PLKLinkTokenConfiguration( + token: "test-token", + onSuccess: { success in + capturedObjCSuccess = success + } + ) + config.onExit = { _ in } + config.onEvent = { _ in } + + let swiftConfig = config.toSwift + + // Create Swift LinkSuccess from JSON + let jsonString = """ + { + "public_token": "public-token-123", + "metadata": { + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "accounts": [ + { + "id": "acc_1", + "name": "Checking", + "mask": "1234", + "subtype": "depository.checking" + } + ], + "link_session_id": "session_123" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkSuccess = try decoder.decode(LinkSuccess.self, from: data) + + // Call the Swift onSuccess handler + swiftConfig.onSuccess(linkSuccess) + + // Verify ObjC handler was called with converted data + XCTAssertNotNil(capturedObjCSuccess) + XCTAssertEqual(capturedObjCSuccess?.publicToken, "public-token-123") + XCTAssertEqual(capturedObjCSuccess?.metadata.linkSessionID, "session_123") + XCTAssertEqual(capturedObjCSuccess?.metadata.institution.id, "ins_123") + XCTAssertEqual(capturedObjCSuccess?.metadata.institution.name, "Test Bank") + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.count, 1) + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.first?.id, "acc_1") + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.first?.name, "Checking") + XCTAssertEqual(capturedObjCSuccess?.metadata.accounts.first?.mask, "1234") + } + + func testOnExitHandlerIsCalled() throws { + var capturedObjCExit: PLKLinkExit? + let config = PLKLinkTokenConfiguration( + token: "test-token", + onSuccess: { _ in } + ) + config.onExit = { exit in + capturedObjCExit = exit + } + config.onEvent = { _ in } + + let swiftConfig = config.toSwift + + // Create Swift LinkExit from JSON + let jsonString = """ + { + "error": { + "errorCode": { + "type": "ITEM_ERROR", + "code": "ITEM_LOGIN_REQUIRED" + }, + "errorMessage": "Item login required", + "displayMessage": "Please log in again" + }, + "metadata": { + "status": "requires_credentials", + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "link_session_id": "session_123", + "request_id": "req_123" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkExit = try decoder.decode(LinkExit.self, from: data) + + // Call the Swift onExit handler + swiftConfig.onExit?(linkExit) + + // Verify ObjC handler was called with converted data + XCTAssertNotNil(capturedObjCExit) + XCTAssertEqual(capturedObjCExit?.metadata.linkSessionID, "session_123") + XCTAssertEqual(capturedObjCExit?.metadata.requestID, "req_123") + XCTAssertEqual(capturedObjCExit?.metadata.institution?.id, "ins_123") + XCTAssertEqual(capturedObjCExit?.metadata.institution?.name, "Test Bank") + XCTAssertNotNil(capturedObjCExit?.error) + XCTAssertEqual(capturedObjCExit?.error?.localizedDescription, "Please log in again") + } + + func testOnEventHandlerIsCalled() throws { + var capturedObjCEvent: PLKLinkEvent? + let config = PLKLinkTokenConfiguration( + token: "test-token", + onSuccess: { _ in } + ) + config.onExit = { _ in } + config.onEvent = { event in + capturedObjCEvent = event + } + + let swiftConfig = config.toSwift + + // Create Swift LinkEvent from JSON + let jsonString = """ + { + "event_name": "OPEN", + "metadata": { + "link_session_id": "session_123", + "request_id": "req_123", + "institution_id": "ins_123", + "institution_name": "Test Bank", + "view_name": "SELECT_INSTITUTION", + "timestamp": "2024-01-21T12:00:00.000Z" + } + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let linkEvent = try decoder.decode(LinkEvent.self, from: data) + + // Call the Swift onEvent handler + swiftConfig.onEvent?(linkEvent) + + // Verify ObjC handler was called with converted data + XCTAssertNotNil(capturedObjCEvent) + XCTAssertEqual(capturedObjCEvent?.eventName.value, .open) + XCTAssertEqual(capturedObjCEvent?.eventMetadata.linkSessionID, "session_123") + XCTAssertEqual(capturedObjCEvent?.eventMetadata.requestID, "req_123") + XCTAssertEqual(capturedObjCEvent?.eventMetadata.institutionID, "ins_123") + XCTAssertEqual(capturedObjCEvent?.eventMetadata.institutionName, "Test Bank") + } + + func testOnLoadHandlerIsCalled() { + var onLoadCalled = false + let config = PLKLinkTokenConfiguration( + token: "test-token", + onSuccess: { _ in } + ) + config.onExit = { _ in } + config.onEvent = { _ in } + config.onLoad = { + onLoadCalled = true + } + + let swiftConfig = config.toSwift + + // Call the Swift onLoad handler + swiftConfig.onLoad?() + + // Verify ObjC handler was called + XCTAssertTrue(onLoadCalled) + } + + func testOnLoadHandlerIsOptional() { + let config = PLKLinkTokenConfiguration( + token: "test-token", + onSuccess: { _ in } + ) + config.onExit = { _ in } + config.onEvent = { _ in } + // Don't set onLoad + + let swiftConfig = config.toSwift + + // Verify onLoad handler exists but doesn't crash when called + XCTAssertNotNil(swiftConfig.onLoad) + swiftConfig.onLoad?() + } + + func testAllHandlersWorkTogether() throws { + var successCalled = false + var exitCalled = false + var eventCalled = false + var loadCalled = false + + let config = PLKLinkTokenConfiguration( + token: "test-token-all", + onSuccess: { _ in successCalled = true } + ) + config.onExit = { _ in exitCalled = true } + config.onEvent = { _ in eventCalled = true } + config.onLoad = { loadCalled = true } + + let swiftConfig = config.toSwift + + // Test onLoad + swiftConfig.onLoad?() + XCTAssertTrue(loadCalled) + + // Test onSuccess + let successJSON = """ + { + "public_token": "token", + "metadata": { + "institution": {"id": "ins", "name": "Bank"}, + "accounts": [{"id": "acc", "name": "Account", "subtype": "depository.checking"}], + "link_session_id": "session" + } + } + """ + let successData = successJSON.data(using: .utf8)! + let linkSuccess = try JSONDecoder().decode(LinkSuccess.self, from: successData) + swiftConfig.onSuccess(linkSuccess) + XCTAssertTrue(successCalled) + + // Test onExit + let exitJSON = """ + { + "metadata": { + "linkSessionID": "session", + "requestID": "req" + } + } + """ + let exitData = exitJSON.data(using: .utf8)! + let linkExit = try JSONDecoder().decode(LinkExit.self, from: exitData) + swiftConfig.onExit?(linkExit) + XCTAssertTrue(exitCalled) + + // Test onEvent + let eventJSON = """ + { + "event_name": "OPEN", + "metadata": { + "link_session_id": "session", + "request_id": "req", + "timestamp": "2024-01-21T12:00:00.000Z" + } + } + """ + let eventData = eventJSON.data(using: .utf8)! + let linkEvent = try JSONDecoder().decode(LinkEvent.self, from: eventData) + swiftConfig.onEvent?(linkEvent) + XCTAssertTrue(eventCalled) + } +} diff --git a/Tests/LinkKitObjCTests/PLKMFATypeTests.swift b/Tests/LinkKitObjCTests/PLKMFATypeTests.swift new file mode 100644 index 0000000..65eb5f8 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKMFATypeTests.swift @@ -0,0 +1,34 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKMFATypeTests: XCTestCase { + + func testMFATypeToObjCMapping() throws { + let testCases: [(MFAType, PLKMFAType)] = [ + (.code, .code), + (.device, .device), + (.questions, .questions), + (.selections, .selections), + ] + + for (swiftValue, objcValue) in testCases { + XCTAssertEqual(swiftValue.toObjC, objcValue, "Expected \(swiftValue) to map to \(objcValue)") + } + } + + func testMFATypeUnknownDefaultFallsBackToNone() throws { + // We cannot construct an unknown MFAType case directly in Swift to hit the @unknown default. + // This test asserts that the Objective-C enum has `.none` available and that known cases + // do not map to `.none`, ensuring that unknowns would fall back to `.none`. + let knownMappings: [MFAType] = [.code, .device, .questions, .selections] + for value in knownMappings { + XCTAssertNotEqual(value.toObjC, .none, "Known MFAType \(value) should not map to .none") + } + + // Sanity: ensure .none exists on the ObjC side + _ = PLKMFAType.none + } +} diff --git a/Tests/LinkKitObjCTests/PLKPlaidConfigurationErrorTests.swift b/Tests/LinkKitObjCTests/PLKPlaidConfigurationErrorTests.swift new file mode 100644 index 0000000..45a1a37 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKPlaidConfigurationErrorTests.swift @@ -0,0 +1,74 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKPlaidConfigurationErrorTests: XCTestCase { + + // MARK: - toObjC Conversion Tests + + func testInvalidTokenToObjC() { + let configError = ConfigurationError.invalidToken(message: "Invalid token provided") + + let objcError = configError.toObjC + + XCTAssertEqual(objcError, .invalidToken) + XCTAssertEqual(objcError.rawValue, 0) + } + + // MARK: - NSError Conversion Tests + + func testInvalidTokenNSError() { + let errorMessage = "The link token is invalid or has expired" + let configError = ConfigurationError.invalidToken(message: errorMessage) + + let nsError = configError.nsError + + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + XCTAssertEqual(nsError.code, PLKPlaidConfigurationError.invalidToken.rawValue) + XCTAssertEqual(nsError.userInfo["message"] as? String, errorMessage) + XCTAssertEqual(nsError.localizedDescription, errorMessage) + } + + func testInvalidTokenNSErrorWithEmptyMessage() { + let configError = ConfigurationError.invalidToken(message: "") + + let nsError = configError.nsError + + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + XCTAssertEqual(nsError.code, PLKPlaidConfigurationError.invalidToken.rawValue) + XCTAssertEqual(nsError.userInfo["message"] as? String, "") + XCTAssertEqual(nsError.localizedDescription, "") + } + + func testInvalidTokenNSErrorWithLongMessage() { + let longMessage = + "This is a very long error message that contains detailed information about why the link token is invalid. It might include information about token expiration, malformed tokens, or other validation errors that occurred during the configuration process." + let configError = ConfigurationError.invalidToken(message: longMessage) + + let nsError = configError.nsError + + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + XCTAssertEqual(nsError.code, PLKPlaidConfigurationError.invalidToken.rawValue) + XCTAssertEqual(nsError.userInfo["message"] as? String, longMessage) + XCTAssertEqual(nsError.localizedDescription, longMessage) + } + + // MARK: - Error Code Raw Value Tests + + func testInvalidTokenRawValue() { + let error = PLKPlaidConfigurationError.invalidToken + XCTAssertEqual(error.rawValue, 0) + } + + // MARK: - Error Domain Tests + + func testErrorDomain() { + let configError = ConfigurationError.invalidToken(message: "Test message") + let nsError = configError.nsError + + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + XCTAssertNotEqual(nsError.domain, "") + } +} diff --git a/Tests/LinkKitObjCTests/PLKPlaidFinanceKitTests.swift b/Tests/LinkKitObjCTests/PLKPlaidFinanceKitTests.swift new file mode 100644 index 0000000..853e221 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKPlaidFinanceKitTests.swift @@ -0,0 +1,241 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +#if canImport(FinanceKit) && compiler(>=5.10) && !targetEnvironment(macCatalyst) +@available(iOS 17.4, *) +final class PLKPlaidFinanceKitTests: XCTestCase { + + // MARK: - SyncBehavior Tests + + func testSyncBehaviorLiveConvertsToSwift() { + let objcBehavior = PLKPlaidFinanceKit.PLKSyncBehavior.live + let swiftBehavior = objcBehavior.swiftValue + + // Verify it converts to the Swift enum properly + switch swiftBehavior { + case .live: + XCTAssertTrue(true, "Correctly converted to .live") + case .simulated: + XCTFail("Should have converted to .live, not .simulated") + @unknown default: + XCTFail("unknown default") + } + } + + func testSyncBehaviorSimulatedConvertsToSwift() { + let objcBehavior = PLKPlaidFinanceKit.PLKSyncBehavior.simulated + let swiftBehavior = objcBehavior.swiftValue + + // Verify it converts to the Swift enum properly + switch swiftBehavior { + case .simulated: + XCTAssertTrue(true, "Correctly converted to .simulated") + case .live: + XCTFail("Should have converted to .simulated, not .live") + @unknown default: + XCTFail("unknown default") + } + } + + func testSyncBehaviorRawValues() { + // Verify the Int raw values are stable + XCTAssertEqual(PLKPlaidFinanceKit.PLKSyncBehavior.live.rawValue, 0) + XCTAssertEqual(PLKPlaidFinanceKit.PLKSyncBehavior.simulated.rawValue, 1) + } + + // MARK: - FinanceKitError Conversion Tests + + func testInvalidTokenErrorConvertsToObjC() { + let swiftError = FinanceKitError.invalidToken(message: "Invalid token format") + let objcError = swiftError.toObjC + + XCTAssertEqual(objcError, .invalidToken) + XCTAssertEqual(objcError.rawValue, 0) + } + + func testPermissionErrorConvertsToObjC() { + let swiftError = FinanceKitError.permissionError(message: "Permission denied") + let objcError = swiftError.toObjC + + XCTAssertEqual(objcError, .permissionError) + XCTAssertEqual(objcError.rawValue, 1) + } + + func testLinkApiErrorConvertsToObjC() { + let swiftError = FinanceKitError.linkApiError(message: "API request failed") + let objcError = swiftError.toObjC + + XCTAssertEqual(objcError, .linkApiError) + XCTAssertEqual(objcError.rawValue, 2) + } + + func testPermissionAccessErrorConvertsToObjC() { + let swiftError = FinanceKitError.permissionAccessError(message: "Cannot access permission") + let objcError = swiftError.toObjC + + XCTAssertEqual(objcError, .permissionAccessError) + XCTAssertEqual(objcError.rawValue, 3) + } + + func testUnknownErrorConvertsToObjC() { + let underlyingError = NSError(domain: "TestDomain", code: 999, userInfo: nil) + let swiftError = FinanceKitError.unknown(error: underlyingError) + let objcError = swiftError.toObjC + + XCTAssertEqual(objcError, .unknown) + XCTAssertEqual(objcError.rawValue, 4) + } + + // MARK: - NSError Conversion Tests + + func testInvalidTokenErrorConvertsToNSError() { + let message = "Invalid token format" + let swiftError = FinanceKitError.invalidToken(message: message) + let nsError = swiftError.toNSError + + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + XCTAssertEqual(nsError.code, PLKPlaidFinanceKitError.invalidToken.rawValue) + XCTAssertEqual(nsError.userInfo["message"] as? String, message) + XCTAssertEqual(nsError.localizedDescription, message) + } + + func testPermissionErrorConvertsToNSError() { + let message = "Permission denied by user" + let swiftError = FinanceKitError.permissionError(message: message) + let nsError = swiftError.toNSError + + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + XCTAssertEqual(nsError.code, PLKPlaidFinanceKitError.permissionError.rawValue) + XCTAssertEqual(nsError.userInfo["message"] as? String, message) + XCTAssertEqual(nsError.localizedDescription, message) + } + + func testLinkApiErrorConvertsToNSError() { + let message = "API request failed with status 500" + let swiftError = FinanceKitError.linkApiError(message: message) + let nsError = swiftError.toNSError + + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + XCTAssertEqual(nsError.code, PLKPlaidFinanceKitError.linkApiError.rawValue) + XCTAssertEqual(nsError.userInfo["message"] as? String, message) + XCTAssertEqual(nsError.localizedDescription, message) + } + + func testPermissionAccessErrorConvertsToNSError() { + let message = "Cannot access FinanceKit permission" + let swiftError = FinanceKitError.permissionAccessError(message: message) + let nsError = swiftError.toNSError + + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + XCTAssertEqual(nsError.code, PLKPlaidFinanceKitError.permissionAccessError.rawValue) + XCTAssertEqual(nsError.userInfo["message"] as? String, message) + XCTAssertEqual(nsError.localizedDescription, message) + } + + func testUnknownErrorConvertsToNSError() { + let underlyingMessage = "Something went wrong" + let underlyingError = NSError( + domain: "TestDomain", + code: 999, + userInfo: [NSLocalizedDescriptionKey: underlyingMessage] + ) + let swiftError = FinanceKitError.unknown(error: underlyingError) + let nsError = swiftError.toNSError + + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + XCTAssertEqual(nsError.code, PLKPlaidFinanceKitError.unknown.rawValue) + XCTAssertEqual(nsError.userInfo["message"] as? String, underlyingMessage) + XCTAssertEqual(nsError.localizedDescription, underlyingMessage) + } + + // MARK: - PLKPlaidFinanceKitError Raw Values + + func testPlaidFinanceKitErrorRawValues() { + // Ensure enum raw values are stable for Objective-C compatibility + XCTAssertEqual(PLKPlaidFinanceKitError.invalidToken.rawValue, 0) + XCTAssertEqual(PLKPlaidFinanceKitError.permissionError.rawValue, 1) + XCTAssertEqual(PLKPlaidFinanceKitError.linkApiError.rawValue, 2) + XCTAssertEqual(PLKPlaidFinanceKitError.permissionAccessError.rawValue, 3) + XCTAssertEqual(PLKPlaidFinanceKitError.unknown.rawValue, 4) + } + + // MARK: - Integration Tests + + func testAllErrorTypesConvertCorrectly() { + let testCases: [(FinanceKitError, PLKPlaidFinanceKitError)] = [ + (.invalidToken(message: "test"), .invalidToken), + (.permissionError(message: "test"), .permissionError), + (.linkApiError(message: "test"), .linkApiError), + (.permissionAccessError(message: "test"), .permissionAccessError), + (.unknown(error: NSError(domain: "test", code: 0)), .unknown), + ] + + for (swiftError, expectedObjcError) in testCases { + XCTAssertEqual(swiftError.toObjC, expectedObjcError) + } + } + + func testErrorMessagesPreservedInNSError() { + let testMessages = [ + "Token is malformed", + "User denied FinanceKit access", + "Network request timed out", + "Cannot read FinanceKit permission status", + "Unexpected error occurred", + ] + + let errors: [FinanceKitError] = [ + .invalidToken(message: testMessages[0]), + .permissionError(message: testMessages[1]), + .linkApiError(message: testMessages[2]), + .permissionAccessError(message: testMessages[3]), + .unknown( + error: NSError( + domain: "test", + code: 0, + userInfo: [NSLocalizedDescriptionKey: testMessages[4]] + ) + ), + ] + + for (index, error) in errors.enumerated() { + let nsError = error.toNSError + XCTAssertEqual(nsError.localizedDescription, testMessages[index]) + XCTAssertEqual(nsError.userInfo["message"] as? String, testMessages[index]) + } + } + + func testNSErrorDomainIsConsistent() { + let errors: [FinanceKitError] = [ + .invalidToken(message: "test"), + .permissionError(message: "test"), + .linkApiError(message: "test"), + .permissionAccessError(message: "test"), + .unknown(error: NSError(domain: "test", code: 0)), + ] + + for error in errors { + let nsError = error.toNSError + XCTAssertEqual(nsError.domain, kPLKDefaultErrorDomain) + } + } + + func testNSErrorCodesMatchObjCEnumValues() { + let testCases: [(FinanceKitError, Int)] = [ + (.invalidToken(message: ""), PLKPlaidFinanceKitError.invalidToken.rawValue), + (.permissionError(message: ""), PLKPlaidFinanceKitError.permissionError.rawValue), + (.linkApiError(message: ""), PLKPlaidFinanceKitError.linkApiError.rawValue), + (.permissionAccessError(message: ""), PLKPlaidFinanceKitError.permissionAccessError.rawValue), + (.unknown(error: NSError(domain: "", code: 0)), PLKPlaidFinanceKitError.unknown.rawValue), + ] + + for (swiftError, expectedCode) in testCases { + let nsError = swiftError.toNSError + XCTAssertEqual(nsError.code, expectedCode) + } + } +} +#endif // canImport(FinanceKit) && compiler(>=5.10) && !targetEnvironment(macCatalyst) diff --git a/Tests/LinkKitObjCTests/PLKPlaidHeadlessSessionTests.swift b/Tests/LinkKitObjCTests/PLKPlaidHeadlessSessionTests.swift new file mode 100644 index 0000000..a012356 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKPlaidHeadlessSessionTests.swift @@ -0,0 +1,77 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKPlaidHeadlessSessionTests: XCTestCase { + + func testInitializationWithSession() { + let mockSession = MockPlaidHeadlessSession() + let shim = PLKPlaidHeadlessSessionShim(session: mockSession) + + XCTAssertNotNil(shim) + } + + func testStartDelegatesToUnderlyingSession() { + let mockSession = MockPlaidHeadlessSession() + let shim = PLKPlaidHeadlessSessionShim(session: mockSession) + + XCTAssertFalse(mockSession.startCalled) + + shim.start() + + XCTAssertTrue(mockSession.startCalled) + } + + func testStartCanBeCalledMultipleTimes() { + let mockSession = MockPlaidHeadlessSession() + let shim = PLKPlaidHeadlessSessionShim(session: mockSession) + + shim.start() + shim.start() + shim.start() + + XCTAssertEqual(mockSession.startCallCount, 3) + } + + func testConformsToObjCProtocol() { + let mockSession = MockPlaidHeadlessSession() + let shim = PLKPlaidHeadlessSessionShim(session: mockSession) + + // Verify it can be used through the protocol + let protocolInstance: PLKPlaidHeadlessSession = shim + protocolInstance.start() + + XCTAssertTrue(mockSession.startCalled) + } + + func testCanBeUsedFromObjectiveCContext() { + let mockSession = MockPlaidHeadlessSession() + let shim = PLKPlaidHeadlessSessionShim(session: mockSession) + + // Verify the shim can be stored in an NSObject-typed variable + let nsObject: NSObject = shim + XCTAssertNotNil(nsObject) + + // Verify we can still call protocol methods through NSObject reference + if let headlessSession = nsObject as? PLKPlaidHeadlessSession { + headlessSession.start() + XCTAssertTrue(mockSession.startCalled) + } else { + XCTFail("Shim should conform to PLKPlaidHeadlessSession") + } + } +} + +// MARK: - Mock + +private class MockPlaidHeadlessSession: PlaidHeadlessSession { + var startCalled = false + var startCallCount = 0 + + func start() { + startCalled = true + startCallCount += 1 + } +} diff --git a/Tests/LinkKitObjCTests/PLKPlaidLayerSessionTests.swift b/Tests/LinkKitObjCTests/PLKPlaidLayerSessionTests.swift new file mode 100644 index 0000000..caaa5ef --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKPlaidLayerSessionTests.swift @@ -0,0 +1,235 @@ +import LinkKit +import UIKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKPlaidLayerSessionTests: XCTestCase { + + // MARK: - Initialization Tests + + func testInitializationWithPlaidLayerSession() throws { + let config = LayerTokenConfiguration( + token: "link-sandbox-123", + onSuccess: { _ in }, + onExit: nil, + onEvent: nil + ) + + let session = try Plaid.createPlaidLayerSession(configuration: config) + let shim = PLKPlaidLayerSessionShim(session: session) + XCTAssertNotNil(shim) + } + + // MARK: - Submit Tests + + func testSubmitWithPhoneNumber() { + let mockSession = MockLayerSession() + let shim = PLKPlaidLayerSessionShim(session: mockSession) + + let submissionData = PLKSubmissionData() + submissionData.phoneNumber = "555-1234" + submissionData.dateOfBirth = "1990-01-01" + + shim.submit(submissionData) + + XCTAssertTrue(mockSession.submitCalled) + XCTAssertEqual(mockSession.lastSubmittedData?.phoneNumber, "555-1234") + XCTAssertEqual(mockSession.lastSubmittedData?.dateOfBirth, "1990-01-01") + } + + func testSubmitWithNilPhoneNumber() { + let mockSession = MockLayerSession() + let shim = PLKPlaidLayerSessionShim(session: mockSession) + + let submissionData = PLKSubmissionData() + submissionData.phoneNumber = nil + + shim.submit(submissionData) + + XCTAssertTrue(mockSession.submitCalled) + XCTAssertNil(mockSession.lastSubmittedData?.phoneNumber) + } + + func testSubmitWithDateOfBirth() { + let mockSession = MockLayerSession() + let shim = PLKPlaidLayerSessionShim(session: mockSession) + + let submissionData = PLKSubmissionData() + submissionData.phoneNumber = "555-9999" + submissionData.dateOfBirth = "1985-12-25" + + shim.submit(submissionData) + + XCTAssertTrue(mockSession.submitCalled) + XCTAssertEqual(mockSession.lastSubmittedData?.dateOfBirth, "1985-12-25") + } + + func testSubmitWithNilDateOfBirth() { + let mockSession = MockLayerSession() + let shim = PLKPlaidLayerSessionShim(session: mockSession) + + let submissionData = PLKSubmissionData() + submissionData.phoneNumber = "555-0000" + submissionData.dateOfBirth = nil + + shim.submit(submissionData) + + XCTAssertTrue(mockSession.submitCalled) + XCTAssertNil(mockSession.lastSubmittedData?.dateOfBirth) + } + + func testSubmitMultipleTimes() { + let mockSession = MockLayerSession() + let shim = PLKPlaidLayerSessionShim(session: mockSession) + + let submissionData1 = PLKSubmissionData() + submissionData1.phoneNumber = "555-1111" + shim.submit(submissionData1) + + let submissionData2 = PLKSubmissionData() + submissionData2.phoneNumber = "555-2222" + shim.submit(submissionData2) + + XCTAssertEqual(mockSession.submitCallCount, 2) + XCTAssertEqual(mockSession.lastSubmittedData?.phoneNumber, "555-2222") + } + + // MARK: - Open Tests + + func testOpenWithCustomPresentationMethod() { + let mockSession = MockLayerSession() + let shim = PLKPlaidLayerSessionShim(session: mockSession) + + var presentationHandlerCalled = false + var dismissalHandlerCalled = false + + let presentationHandler: PLKPresentationHandler = { _ in + presentationHandlerCalled = true + } + + let dismissalHandler: PLKDismissalHandler = { _ in + dismissalHandlerCalled = true + } + + shim.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + + XCTAssertTrue(mockSession.openCalled) + XCTAssertNotNil(mockSession.lastPresentationMethod) + + // Verify the presentation method is .custom + if case .custom(let presentHandler, let dismissHandler) = mockSession.lastPresentationMethod { + let dummyVC = UIViewController() + presentHandler(dummyVC) + dismissHandler(dummyVC) + + XCTAssertTrue(presentationHandlerCalled) + XCTAssertTrue(dismissalHandlerCalled) + } else { + XCTFail("Expected .custom presentation method") + } + } + + func testOpenCallsSessionOpen() { + let mockSession = MockLayerSession() + let shim = PLKPlaidLayerSessionShim(session: mockSession) + + let presentationHandler: PLKPresentationHandler = { _ in } + let dismissalHandler: PLKDismissalHandler = { _ in } + + shim.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + + XCTAssertTrue(mockSession.openCalled) + XCTAssertEqual(mockSession.openCallCount, 1) + } + + func testOpenMultipleTimes() { + let mockSession = MockLayerSession() + let shim = PLKPlaidLayerSessionShim(session: mockSession) + + let presentationHandler: PLKPresentationHandler = { _ in } + let dismissalHandler: PLKDismissalHandler = { _ in } + + shim.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + shim.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + + XCTAssertEqual(mockSession.openCallCount, 2) + } + + // MARK: - SwiftPLKSubmissionData Tests + + func testSwiftPLKSubmissionDataConvertsPhoneNumber() { + let objcData = PLKSubmissionData() + objcData.phoneNumber = "555-TEST" + let swiftData = PLKPlaidLayerSessionShim.SwiftPLKSubmissionData(data: objcData) + + XCTAssertEqual(swiftData.phoneNumber, "555-TEST") + } + + func testSwiftPLKSubmissionDataConvertsDateOfBirth() { + let objcData = PLKSubmissionData() + objcData.dateOfBirth = "2000-06-15" + objcData.phoneNumber = "555-1234" + + let swiftData = PLKPlaidLayerSessionShim.SwiftPLKSubmissionData(data: objcData) + + XCTAssertEqual(swiftData.dateOfBirth, "2000-06-15") + } + + func testSwiftPLKSubmissionDataHandlesNilValues() { + let objcData = PLKSubmissionData() + objcData.dateOfBirth = nil + + let swiftData = PLKPlaidLayerSessionShim.SwiftPLKSubmissionData(data: objcData) + + XCTAssertNil(swiftData.phoneNumber) + XCTAssertNil(swiftData.dateOfBirth) + } + + func testSwiftPLKSubmissionDataParamsIsNil() { + let objcData = PLKSubmissionData() + objcData.phoneNumber = "555-0000" + let swiftData = PLKPlaidLayerSessionShim.SwiftPLKSubmissionData(data: objcData) + + XCTAssertNil(swiftData.params) + } + + // MARK: - Integration Tests + + func testCompleteWorkflow() { + let mockSession = MockLayerSession() + let shim = PLKPlaidLayerSessionShim(session: mockSession) + + var presentationCalled = false + var dismissalCalled = false + + // Open the session + shim.open( + presentationHandler: { _ in presentationCalled = true }, + dismissalHandler: { _ in dismissalCalled = true } + ) + + XCTAssertTrue(mockSession.openCalled) + + // Submit data + let submissionData = PLKSubmissionData() + submissionData.phoneNumber = "555-FULL" + submissionData.dateOfBirth = "1995-03-20" + shim.submit(submissionData) + + XCTAssertTrue(mockSession.submitCalled) + XCTAssertEqual(mockSession.lastSubmittedData?.phoneNumber, "555-FULL") + XCTAssertEqual(mockSession.lastSubmittedData?.dateOfBirth, "1995-03-20") + + // Trigger handlers + if case .custom(let presentHandler, let dismissHandler) = mockSession.lastPresentationMethod { + let vc = UIViewController() + presentHandler(vc) + dismissHandler(vc) + + XCTAssertTrue(presentationCalled) + XCTAssertTrue(dismissalCalled) + } + } +} diff --git a/Tests/LinkKitObjCTests/PLKPlaidLinkSessionTests.swift b/Tests/LinkKitObjCTests/PLKPlaidLinkSessionTests.swift new file mode 100644 index 0000000..3cff1b0 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKPlaidLinkSessionTests.swift @@ -0,0 +1,193 @@ +import LinkKit +import UIKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKPlaidLinkSessionTests: XCTestCase { + + func testInitializationWithSession() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + XCTAssertNotNil(shim) + } + + func testShowGradientBackgroundDefaultValue() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + XCTAssertFalse(shim.showGradientBackground) + } + + func testShowGradientBackgroundSetsUnderlyingSession() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + XCTAssertFalse(mockSession.showGradientBackground) + + shim.showGradientBackground = true + + XCTAssertTrue(shim.showGradientBackground) + XCTAssertTrue(mockSession.showGradientBackground) + } + + func testShowGradientBackgroundCanBeToggledMultipleTimes() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + shim.showGradientBackground = true + XCTAssertTrue(mockSession.showGradientBackground) + + shim.showGradientBackground = false + XCTAssertFalse(mockSession.showGradientBackground) + + shim.showGradientBackground = true + XCTAssertTrue(mockSession.showGradientBackground) + } + + func testOpenDelegatesToUnderlyingSession() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + XCTAssertFalse(mockSession.openCalled) + + let presentationHandler: PLKPresentationHandler = { _ in } + let dismissalHandler: PLKDismissalHandler = { _ in } + + shim.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + + XCTAssertTrue(mockSession.openCalled) + } + + func testOpenUsesCustomPresentationMethod() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + var presentationCalled = false + var dismissalCalled = false + + let presentationHandler: PLKPresentationHandler = { _ in + presentationCalled = true + } + let dismissalHandler: PLKDismissalHandler = { _ in + dismissalCalled = true + } + + shim.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + + XCTAssertNotNil(mockSession.lastPresentationMethod) + + // Verify the presentation method is .custom + if case .custom(let presentHandler, let dismissHandler) = mockSession.lastPresentationMethod { + let dummyVC = UIViewController() + presentHandler(dummyVC) + dismissHandler(dummyVC) + + XCTAssertTrue(presentationCalled) + XCTAssertTrue(dismissalCalled) + } else { + XCTFail("Expected .custom presentation method") + } + } + + func testOpenCanBeCalledMultipleTimes() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + let presentationHandler: PLKPresentationHandler = { _ in } + let dismissalHandler: PLKDismissalHandler = { _ in } + + shim.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + shim.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + shim.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + + XCTAssertEqual(mockSession.openCallCount, 3) + } + + func testConformsToObjCProtocol() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + // Verify it can be used through the protocol + let protocolInstance: PLKPlaidLinkSession = shim + + protocolInstance.showGradientBackground = true + XCTAssertTrue(mockSession.showGradientBackground) + + let presentationHandler: PLKPresentationHandler = { _ in } + let dismissalHandler: PLKDismissalHandler = { _ in } + protocolInstance.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + + XCTAssertTrue(mockSession.openCalled) + } + + func testCanBeUsedFromObjectiveCContext() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + // Verify the shim can be stored in an NSObject-typed variable + let nsObject: NSObject = shim + XCTAssertNotNil(nsObject) + + // Verify we can still call protocol methods through NSObject reference + if let linkSession = nsObject as? PLKPlaidLinkSession { + linkSession.showGradientBackground = true + XCTAssertTrue(mockSession.showGradientBackground) + + let presentationHandler: PLKPresentationHandler = { _ in } + let dismissalHandler: PLKDismissalHandler = { _ in } + linkSession.open(presentationHandler: presentationHandler, dismissalHandler: dismissalHandler) + + XCTAssertTrue(mockSession.openCalled) + } else { + XCTFail("Shim should conform to PLKPlaidLinkSession") + } + } + + func testShowGradientBackgroundPropertyIsIndependent() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + // Changing the shim's property should update the session + shim.showGradientBackground = true + XCTAssertTrue(mockSession.showGradientBackground) + + // But changing the session directly doesn't update the shim's stored value + mockSession.showGradientBackground = false + XCTAssertTrue(shim.showGradientBackground) // Shim still has its own value + } + + func testCompleteWorkflow() { + let mockSession = MockPlaidLinkSession() + let shim = PLKPlaidLinkSessionShim(session: mockSession) + + // Set gradient background + shim.showGradientBackground = true + XCTAssertTrue(mockSession.showGradientBackground) + + // Open the session + var presentationCalled = false + var dismissalCalled = false + + shim.open( + presentationHandler: { _ in presentationCalled = true }, + dismissalHandler: { _ in dismissalCalled = true } + ) + + XCTAssertTrue(mockSession.openCalled) + + // Trigger handlers + if case .custom(let presentHandler, let dismissHandler) = mockSession.lastPresentationMethod { + let vc = UIViewController() + presentHandler(vc) + dismissHandler(vc) + + XCTAssertTrue(presentationCalled) + XCTAssertTrue(dismissalCalled) + } else { + XCTFail("Expected .custom presentation method") + } + } +} diff --git a/Tests/LinkKitObjCTests/PLKRateLimitErrorCodeTests.swift b/Tests/LinkKitObjCTests/PLKRateLimitErrorCodeTests.swift new file mode 100644 index 0000000..6f90f86 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKRateLimitErrorCodeTests.swift @@ -0,0 +1,98 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKRateLimitErrorCodeTests: XCTestCase { + + func testAccountsLimitToObjC() { + let errorCode = RateLimitErrorCode.accountsLimit + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .accountsLimit) + } + + func testAdditionLimitToObjC() { + let errorCode = RateLimitErrorCode.additionLimit + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .additionLimit) + } + + func testAuthLimitToObjC() { + let errorCode = RateLimitErrorCode.authLimit + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .authLimit) + } + + func testIdentityLimitToObjC() { + let errorCode = RateLimitErrorCode.identityLimit + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .identityLimit) + } + + func testIncomeLimitToObjC() { + let errorCode = RateLimitErrorCode.incomeLimit + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .incomeLimit) + } + + func testItemGetLimitToObjC() { + let errorCode = RateLimitErrorCode.itemGetLimit + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .itemGetLimit) + } + + func testRateLimitToObjC() { + let errorCode = RateLimitErrorCode.rateLimit + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .rateLimit) + } + + func testTransactionsLimitToObjC() { + let errorCode = RateLimitErrorCode.transactionsLimit + let objcErrorCode = errorCode.toObjC + XCTAssertEqual(objcErrorCode, .transactionsLimit) + } + + func testUnknownToObjC() { + let errorCode = RateLimitErrorCode.unknown("CUSTOM_RATE_LIMIT_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } + + func testUnknownWithDifferentStringToObjC() { + let errorCode = RateLimitErrorCode.unknown("ANOTHER_UNKNOWN_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } + + func testUnknownWithEmptyStringToObjC() { + let errorCode = RateLimitErrorCode.unknown("") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode) + } + + func testAllKnownErrorCodesMapToObjC() { + let testCases: [(RateLimitErrorCode, PLKRateLimitErrorCode)] = [ + (.accountsLimit, .accountsLimit), + (.additionLimit, .additionLimit), + (.authLimit, .authLimit), + (.identityLimit, .identityLimit), + (.incomeLimit, .incomeLimit), + (.itemGetLimit, .itemGetLimit), + (.rateLimit, .rateLimit), + (.transactionsLimit, .transactionsLimit), + ] + + for (swiftCode, expectedObjCCode) in testCases { + let objcCode = swiftCode.toObjC + XCTAssertEqual(objcCode, expectedObjCCode, "Failed for \(swiftCode)") + } + } + + func testToObjCReturnsNilForUnknownDefault() { + let errorCode = RateLimitErrorCode.unknown("UNHANDLED_ERROR") + let objcErrorCode = errorCode.toObjC + XCTAssertNil(objcErrorCode, "Unknown error codes should return nil") + } +} diff --git a/Tests/LinkKitObjCTests/PLKSuccessMetadataTests.swift b/Tests/LinkKitObjCTests/PLKSuccessMetadataTests.swift new file mode 100644 index 0000000..649cd78 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKSuccessMetadataTests.swift @@ -0,0 +1,342 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC + +final class PLKSuccessMetadataTests: XCTestCase { + + func testSuccessMetadataWithSingleAccountToObjC() throws { + let jsonString = """ + { + "institution": { + "id": "ins_123", + "name": "Test Bank" + }, + "accounts": [{ + "id": "acc_123", + "name": "Checking", + "mask": "0000", + "subtype": "depository.checking", + "verificationStatus": "manually_verified" + }], + "link_session_id": "link_session_123" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let successMetadata = try decoder.decode(SuccessMetadata.self, from: data) + + let objcMetadata = successMetadata.toObjC + + XCTAssertEqual(objcMetadata.linkSessionID, "link_session_123") + XCTAssertEqual(objcMetadata.institution.id, "ins_123") + XCTAssertEqual(objcMetadata.institution.name, "Test Bank") + XCTAssertEqual(objcMetadata.accounts.count, 1) + XCTAssertEqual(objcMetadata.accounts[0].id, "acc_123") + XCTAssertEqual(objcMetadata.accounts[0].name, "Checking") + XCTAssertEqual(objcMetadata.accounts[0].mask, "0000") + XCTAssertNil(objcMetadata.metadataJSON) + } + + func testSuccessMetadataWithMultipleAccountsToObjC() throws { + let jsonString = """ + { + "institution": { + "id": "ins_456", + "name": "Another Bank" + }, + "accounts": [ + { + "id": "acc_1", + "name": "Checking", + "mask": "0001", + "subtype": "depository.checking", + "verificationStatus": "manually_verified" + }, + { + "id": "acc_2", + "name": "Savings", + "mask": "0002", + "subtype": "depository.savings", + "verificationStatus": "pending_automatic_verification" + }, + { + "id": "acc_3", + "name": "Credit Card", + "mask": "1234", + "subtype": "credit.credit card" + } + ], + "link_session_id": "link_session_456" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let successMetadata = try decoder.decode(SuccessMetadata.self, from: data) + + let objcMetadata = successMetadata.toObjC + + XCTAssertEqual(objcMetadata.linkSessionID, "link_session_456") + XCTAssertEqual(objcMetadata.institution.id, "ins_456") + XCTAssertEqual(objcMetadata.institution.name, "Another Bank") + XCTAssertEqual(objcMetadata.accounts.count, 3) + + XCTAssertEqual(objcMetadata.accounts[0].id, "acc_1") + XCTAssertEqual(objcMetadata.accounts[0].name, "Checking") + XCTAssertEqual(objcMetadata.accounts[0].mask, "0001") + + XCTAssertEqual(objcMetadata.accounts[1].id, "acc_2") + XCTAssertEqual(objcMetadata.accounts[1].name, "Savings") + XCTAssertEqual(objcMetadata.accounts[1].mask, "0002") + + XCTAssertEqual(objcMetadata.accounts[2].id, "acc_3") + XCTAssertEqual(objcMetadata.accounts[2].name, "Credit Card") + XCTAssertEqual(objcMetadata.accounts[2].mask, "1234") + + XCTAssertNil(objcMetadata.metadataJSON) + } + + func testSuccessMetadataWithMetadataJSONToObjC() throws { + let jsonString = """ + { + "institution": { + "id": "ins_789", + "name": "Bank with Metadata" + }, + "accounts": [{ + "id": "acc_789", + "name": "Account", + "mask": "9999", + "subtype": "depository.checking" + }], + "link_session_id": "link_session_789", + "metadata_json": "json_string" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let successMetadata = try decoder.decode(SuccessMetadata.self, from: data) + + let objcMetadata = successMetadata.toObjC + + XCTAssertEqual(objcMetadata.linkSessionID, "link_session_789") + XCTAssertEqual(objcMetadata.institution.id, "ins_789") + XCTAssertEqual(objcMetadata.institution.name, "Bank with Metadata") + XCTAssertEqual(objcMetadata.accounts.count, 1) + XCTAssertNotNil(objcMetadata.metadataJSON) + XCTAssertEqual(objcMetadata.metadataJSON, "json_string") + } + + func testSuccessMetadataPreservesLinkSessionID() throws { + let jsonString = """ + { + "institution": { + "id": "ins_test", + "name": "Test Institution" + }, + "accounts": [{ + "id": "acc_test", + "name": "Test Account", + "subtype": "depository.checking" + }], + "link_session_id": "unique_link_session_id_12345" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let successMetadata = try decoder.decode(SuccessMetadata.self, from: data) + + let objcMetadata = successMetadata.toObjC + + XCTAssertEqual(objcMetadata.linkSessionID, "unique_link_session_id_12345") + } + + func testSuccessMetadataInstitutionConvertsCorrectly() throws { + let jsonString = """ + { + "institution": { + "id": "ins_complex", + "name": "Complex Institution Name & Co." + }, + "accounts": [{ + "id": "acc_minimal", + "name": "Minimal Account", + "subtype": "depository.checking" + }], + "link_session_id": "session_123" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let successMetadata = try decoder.decode(SuccessMetadata.self, from: data) + + let objcMetadata = successMetadata.toObjC + + XCTAssertEqual(objcMetadata.institution.id, "ins_complex") + XCTAssertEqual(objcMetadata.institution.name, "Complex Institution Name & Co.") + } + + func testSuccessMetadataAccountsArrayMapCorrectly() throws { + let jsonString = """ + { + "institution": { + "id": "ins_array_test", + "name": "Array Test Bank" + }, + "accounts": [ + { + "id": "acc_first", + "name": "First Account", + "mask": "1111", + "subtype": "depository.checking" + }, + { + "id": "acc_second", + "name": "Second Account", + "mask": "2222", + "subtype": "depository.savings" + }, + { + "id": "acc_third", + "name": "Third Account", + "mask": "3333", + "subtype": "credit.credit card" + }, + { + "id": "acc_fourth", + "name": "Fourth Account", + "mask": "4444", + "subtype": "investment.401k" + } + ], + "link_session_id": "session_array_test" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let successMetadata = try decoder.decode(SuccessMetadata.self, from: data) + + let objcMetadata = successMetadata.toObjC + + XCTAssertEqual(objcMetadata.accounts.count, 4) + XCTAssertEqual(objcMetadata.accounts[0].id, "acc_first") + XCTAssertEqual(objcMetadata.accounts[1].id, "acc_second") + XCTAssertEqual(objcMetadata.accounts[2].id, "acc_third") + XCTAssertEqual(objcMetadata.accounts[3].id, "acc_fourth") + } + + func testSuccessMetadataWithEmptyMetadataJSONToObjC() throws { + let jsonString = """ + { + "institution": { + "id": "ins_empty", + "name": "Empty Metadata Bank" + }, + "accounts": [{ + "id": "acc_empty", + "name": "Empty Metadata Account", + "subtype": "depository.checking" + }], + "link_session_id": "session_empty" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let successMetadata = try decoder.decode(SuccessMetadata.self, from: data) + + let objcMetadata = successMetadata.toObjC + + XCTAssertNil(objcMetadata.metadataJSON) + } + + func testSuccessMetadataMultipleConversionsProduceSameResult() throws { + let jsonString = """ + { + "institution": { + "id": "ins_repeat", + "name": "Repeat Bank" + }, + "accounts": [{ + "id": "acc_repeat", + "name": "Repeat Account", + "mask": "5555", + "subtype": "depository.checking" + }], + "link_session_id": "session_repeat", + "metadata_json": "{\\"test\\":\\"value\\"}" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let successMetadata = try decoder.decode(SuccessMetadata.self, from: data) + + let objcMetadata1 = successMetadata.toObjC + let objcMetadata2 = successMetadata.toObjC + + XCTAssertEqual(objcMetadata1.linkSessionID, objcMetadata2.linkSessionID) + XCTAssertEqual(objcMetadata1.institution.id, objcMetadata2.institution.id) + XCTAssertEqual(objcMetadata1.institution.name, objcMetadata2.institution.name) + XCTAssertEqual(objcMetadata1.accounts.count, objcMetadata2.accounts.count) + XCTAssertEqual(objcMetadata1.metadataJSON, objcMetadata2.metadataJSON) + } + + func testSuccessMetadataWithComplexAccountSubtypes() throws { + let jsonString = """ + { + "institution": { + "id": "ins_subtypes", + "name": "Subtype Test Bank" + }, + "accounts": [ + { + "id": "acc_checking", + "name": "Checking", + "subtype": "depository.checking" + }, + { + "id": "acc_savings", + "name": "Savings", + "subtype": "depository.savings" + }, + { + "id": "acc_credit", + "name": "Credit Card", + "subtype": "credit.credit card" + }, + { + "id": "acc_401k", + "name": "401k", + "subtype": "investment.401k" + }, + { + "id": "acc_mortgage", + "name": "Mortgage", + "subtype": "loan.mortgage" + } + ], + "link_session_id": "session_subtypes" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let successMetadata = try decoder.decode(SuccessMetadata.self, from: data) + + let objcMetadata = successMetadata.toObjC + + XCTAssertEqual(objcMetadata.accounts.count, 5) + XCTAssertEqual(objcMetadata.accounts[0].id, "acc_checking") + XCTAssertEqual(objcMetadata.accounts[1].id, "acc_savings") + XCTAssertEqual(objcMetadata.accounts[2].id, "acc_credit") + XCTAssertEqual(objcMetadata.accounts[3].id, "acc_401k") + XCTAssertEqual(objcMetadata.accounts[4].id, "acc_mortgage") + } +} diff --git a/Tests/LinkKitObjCTests/PLKVerificationStatusTests.swift b/Tests/LinkKitObjCTests/PLKVerificationStatusTests.swift new file mode 100644 index 0000000..dd00985 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKVerificationStatusTests.swift @@ -0,0 +1,184 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKVerificationStatusTests: XCTestCase { + + func testPendingAutomaticVerificationToObjC() { + let status = VerificationStatus.pendingAutomaticVerification + let objcStatus = status.toObjC + + XCTAssertEqual(objcStatus.value, .pendingAutomaticVerification) + XCTAssertNil(objcStatus.unknownStringValue) + } + + func testPendingManualVerificationToObjC() { + let status = VerificationStatus.pendingManualVerification + let objcStatus = status.toObjC + + XCTAssertEqual(objcStatus.value, .pendingManualVerification) + XCTAssertNil(objcStatus.unknownStringValue) + } + + func testManuallyVerifiedToObjC() { + let status = VerificationStatus.manuallyVerified + let objcStatus = status.toObjC + + XCTAssertEqual(objcStatus.value, .manuallyVerified) + XCTAssertNil(objcStatus.unknownStringValue) + } + + func testUnknownStatusToObjC() { + let status = VerificationStatus.unknown("some_new_verification_status") + let objcStatus = status.toObjC + + XCTAssertEqual(objcStatus.value, .none) + XCTAssertNotNil(objcStatus.unknownStringValue) + XCTAssertEqual(objcStatus.unknownStringValue, "some_new_verification_status") + } + + func testUnknownStatusWithEmptyStringToObjC() { + let status = VerificationStatus.unknown("") + let objcStatus = status.toObjC + + XCTAssertEqual(objcStatus.value, .none) + XCTAssertNotNil(objcStatus.unknownStringValue) + XCTAssertEqual(objcStatus.unknownStringValue, "") + } + + func testUnknownStatusWithComplexStringToObjC() { + let status = VerificationStatus.unknown("future_verification_type_v2") + let objcStatus = status.toObjC + + XCTAssertEqual(objcStatus.value, .none) + XCTAssertNotNil(objcStatus.unknownStringValue) + XCTAssertEqual(objcStatus.unknownStringValue, "future_verification_type_v2") + } + + func testAllKnownStatusesMapToValidObjCValues() { + let knownStatuses: [VerificationStatus] = [ + .pendingAutomaticVerification, + .pendingManualVerification, + .manuallyVerified, + ] + + for status in knownStatuses { + let objcStatus = status.toObjC + XCTAssertNotEqual(objcStatus.value, .none) + XCTAssertNil(objcStatus.unknownStringValue) + } + } + + func testVerificationStatusDecodingAndConversion() throws { + let testCases: [(json: String, expectedValue: PLKVerificationStatusValue, expectedUnknown: String?)] = [ + ( + json: "\"pending_automatic_verification\"", + expectedValue: .pendingAutomaticVerification, + expectedUnknown: nil + ), + ( + json: "\"pending_manual_verification\"", + expectedValue: .pendingManualVerification, + expectedUnknown: nil + ), + ( + json: "\"manually_verified\"", + expectedValue: .manuallyVerified, + expectedUnknown: nil + ), + ( + json: "\"unknown_future_status\"", + expectedValue: .none, + expectedUnknown: "unknown_future_status" + ), + ] + + for testCase in testCases { + let data = testCase.json.data(using: .utf8)! + let decoder = JSONDecoder() + let status = try decoder.decode(VerificationStatus.self, from: data) + let objcStatus = status.toObjC + + XCTAssertEqual(objcStatus.value, testCase.expectedValue) + XCTAssertEqual(objcStatus.unknownStringValue, testCase.expectedUnknown) + } + } + + func testMultipleConversionsProduceSameResult() { + let status = VerificationStatus.pendingAutomaticVerification + + let objcStatus1 = status.toObjC + let objcStatus2 = status.toObjC + + XCTAssertEqual(objcStatus1.value, objcStatus2.value) + XCTAssertEqual(objcStatus1.unknownStringValue, objcStatus2.unknownStringValue) + } + + func testVerificationStatusInAccountContext() throws { + let jsonString = """ + { + "id": "acc_test", + "name": "Test Account", + "mask": "1234", + "subtype": "depository.checking", + "verificationStatus": "pending_automatic_verification" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let account = try decoder.decode(Account.self, from: data) + + XCTAssertEqual(account.verificationStatus, .pendingAutomaticVerification) + + let objcAccount = account.toObjC + XCTAssertEqual(objcAccount.verificationStatus?.value, .pendingAutomaticVerification) + XCTAssertNil(objcAccount.verificationStatus?.unknownStringValue) + } + + func testVerificationStatusNilInAccountContext() throws { + let jsonString = """ + { + "id": "acc_test", + "name": "Test Account", + "mask": "1234", + "subtype": "depository.checking" + } + """ + + let data = jsonString.data(using: .utf8)! + let decoder = JSONDecoder() + let account = try decoder.decode(Account.self, from: data) + + XCTAssertNil(account.verificationStatus) + + let objcAccount = account.toObjC + XCTAssertNil(objcAccount.verificationStatus) + } + + func testVerificationStatusWithDifferentUnknownValues() { + let unknownValues = [ + "new_status_type_1", + "future_verification_method", + "automatically_verified_v2", + "custom_verification_flow", + ] + + for unknownValue in unknownValues { + let status = VerificationStatus.unknown(unknownValue) + let objcStatus = status.toObjC + + XCTAssertEqual(objcStatus.value, .none) + XCTAssertEqual(objcStatus.unknownStringValue, unknownValue) + } + } + + func testVerificationStatusDescriptionUsedForUnknown() { + let status = VerificationStatus.unknown("test_status") + let objcStatus = status.toObjC + + XCTAssertEqual(objcStatus.unknownStringValue, status.description) + } +} diff --git a/Tests/LinkKitObjCTests/PLKViewNameTests.swift b/Tests/LinkKitObjCTests/PLKViewNameTests.swift new file mode 100644 index 0000000..525f8b2 --- /dev/null +++ b/Tests/LinkKitObjCTests/PLKViewNameTests.swift @@ -0,0 +1,321 @@ +import LinkKit +import XCTest + +@testable import LinkKitObjC +@testable import LinkKitObjCInternal + +final class PLKViewNameTests: XCTestCase { + + // MARK: - toObjCValue Tests + + func testAcceptTOSToObjCValue() { + let viewName = ViewName.acceptTOS + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .acceptTOS) + } + + func testConnectedToObjCValue() { + let viewName = ViewName.connected + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .connected) + } + + func testConsentToObjCValue() { + let viewName = ViewName.consent + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .consent) + } + + func testCredentialToObjCValue() { + let viewName = ViewName.credential + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .credential) + } + + func testDocumentaryVerificationToObjCValue() { + let viewName = ViewName.documentaryVerification + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .documentaryVerification) + } + + func testErrorToObjCValue() { + let viewName = ViewName.error + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .error) + } + + func testExitToObjCValue() { + let viewName = ViewName.exit + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .exit) + } + + func testKycCheckToObjCValue() { + let viewName = ViewName.kycCheck + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .kycCheck) + } + + func testLoadingToObjCValue() { + let viewName = ViewName.loading + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .loading) + } + + func testMatchedConsentToObjCValue() { + let viewName = ViewName.matchedConsent + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .matchedConsent) + } + + func testMatchedCredentialToObjCValue() { + let viewName = ViewName.matchedCredential + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .matchedCredential) + } + + func testMatchedMFAToObjCValue() { + let viewName = ViewName.matchedMFA + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .matchedMFA) + } + + func testMFAToObjCValue() { + let viewName = ViewName.mfa + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .MFA) + } + + func testNumbersToObjCValue() { + let viewName = ViewName.numbers + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .numbers) + } + + func testProfileDataReviewToObjCValue() { + let viewName = ViewName.profileDataReview + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .profileDataReview) + } + + func testOAuthToObjCValue() { + let viewName = ViewName.oauth + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .oauth) + } + + func testRecaptchaToObjCValue() { + let viewName = ViewName.recaptcha + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .recaptcha) + } + + func testRiskCheckToObjCValue() { + let viewName = ViewName.riskCheck + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .riskCheck) + } + + func testScreeningToObjCValue() { + let viewName = ViewName.screening + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .screening) + } + + func testSelectAccountToObjCValue() { + let viewName = ViewName.selectAccount + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .selectAccount) + } + + func testSelectInstitutionToObjCValue() { + let viewName = ViewName.selectInstitution + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .selectInstitution) + } + + func testSelfieCheckToObjCValue() { + let viewName = ViewName.selfieCheck + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .selfieCheck) + } + + func testSubmitDocumentsToObjCValue() { + let viewName = ViewName.submitDocuments + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .submitDocuments) + } + + func testSubmitDocumentsSuccessToObjCValue() { + let viewName = ViewName.submitDocumentsSuccess + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .submitDocumentsSuccess) + } + + func testSubmitDocumentsErrorToObjCValue() { + let viewName = ViewName.submitDocumentsError + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .submitDocumentsError) + } + + func testUploadDocumentsToObjCValue() { + let viewName = ViewName.uploadDocuments + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .uploadDocuments) + } + + func testVerifySMSToObjCValue() { + let viewName = ViewName.verifySMS + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .verifySMS) + } + + func testDataTransparencyToObjCValue() { + let viewName = ViewName.dataTransparency + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .dataTransparency) + } + + func testDataTransparencyConsentToObjCValue() { + let viewName = ViewName.dataTransparencyConsent + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .dataTransparencyConsent) + } + + func testSelectAuthTypeToObjCValue() { + let viewName = ViewName.selectAuthType + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .selectAuthType) + } + + func testSelectBrandToObjCValue() { + let viewName = ViewName.selectBrand + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .selectBrand) + } + + func testNumbersSelectInstitutionToObjCValue() { + let viewName = ViewName.numbersSelectInstitution + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .numbersSelectInstitution) + } + + func testSubmitEmailToObjCValue() { + let viewName = ViewName.submitEmail + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .submitEmail) + } + + func testSubmitPhoneToObjCValue() { + let viewName = ViewName.submitPhone + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .submitPhone) + } + + func testVerifyPhoneToObjCValue() { + let viewName = ViewName.verifyPhone + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .verifyPhone) + } + + func testSelectSavedInstitutionToObjCValue() { + let viewName = ViewName.selectSavedInstitution + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .selectSavedInstitution) + } + + func testSelectSavedAccountToObjCValue() { + let viewName = ViewName.selectSavedAccount + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .selectSavedAccount) + } + + func testVerifyEmailToObjCValue() { + let viewName = ViewName.verifyEmail + let objcValue = viewName.toObjCValue + XCTAssertEqual(objcValue, .verifyEmail) + } + + func testUnknownToObjCValue() { + let viewName = ViewName.unknown("CUSTOM_VIEW") + let objcValue = viewName.toObjCValue + XCTAssertNil(objcValue) + } + + // MARK: - toObjC Tests + + func testAcceptTOSToObjC() { + let viewName = ViewName.acceptTOS + let objcViewName = viewName.toObjC + XCTAssertEqual(objcViewName.value, .acceptTOS) + XCTAssertNil(objcViewName.unknownStringValue) + } + + func testMFAToObjC() { + let viewName = ViewName.mfa + let objcViewName = viewName.toObjC + XCTAssertEqual(objcViewName.value, .MFA) + XCTAssertNil(objcViewName.unknownStringValue) + } + + func testSelectInstitutionToObjC() { + let viewName = ViewName.selectInstitution + let objcViewName = viewName.toObjC + XCTAssertEqual(objcViewName.value, .selectInstitution) + XCTAssertNil(objcViewName.unknownStringValue) + } + + func testUnknownToObjCWithCustomString() { + let customView = "CUSTOM_UNKNOWN_VIEW" + let viewName = ViewName.unknown(customView) + let objcViewName = viewName.toObjC + + XCTAssertEqual(objcViewName.value, .none) + XCTAssertEqual(objcViewName.unknownStringValue, customView) + } + + func testUnknownToObjCWithEmptyString() { + let viewName = ViewName.unknown("") + let objcViewName = viewName.toObjC + + XCTAssertEqual(objcViewName.value, .none) + XCTAssertEqual(objcViewName.unknownStringValue, "") + } + + func testDataTransparencyToObjC() { + let viewName = ViewName.dataTransparency + let objcViewName = viewName.toObjC + XCTAssertEqual(objcViewName.value, .dataTransparency) + XCTAssertNil(objcViewName.unknownStringValue) + } + + func testToObjCCreatesObjectWithUnknownStringValue() { + let unknownStrings = [ + "CUSTOM_VIEW_1", + "CUSTOM_VIEW_2", + "FUTURE_VIEW_NAME", + "test_view", + ] + + for unknownString in unknownStrings { + let viewName = ViewName.unknown(unknownString) + let objcViewName = viewName.toObjC + + XCTAssertEqual(objcViewName.value, .none) + XCTAssertEqual(objcViewName.unknownStringValue, unknownString) + } + } + + func testAllKnownViewNamesHaveObjCMapping() { + for viewName in ViewName.allCases { + let objcViewName = viewName.toObjC + + // All known cases should have a valid ObjC mapping (not PLKEventNameValueNone) + XCTAssertNotEqual( + objcViewName.value, + PLKViewNameValue.none, + "View '\(viewName)' must have a valid ObjC mapping, but got .none" + ) + } + } +} From e65290a2f6e493a46eb8ff745cf95d7489ec2563 Mon Sep 17 00:00:00 2001 From: dtroupe Date: Tue, 9 Jun 2026 08:20:00 -0700 Subject: [PATCH 2/2] Run CI only for pull requests --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3a38e5e..c38086a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,6 @@ name: CI on: pull_request: - push: jobs: build: