diff --git a/LineSDK/LineSDK.xcodeproj/project.pbxproj b/LineSDK/LineSDK.xcodeproj/project.pbxproj index 758cc814..de856f00 100644 --- a/LineSDK/LineSDK.xcodeproj/project.pbxproj +++ b/LineSDK/LineSDK.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* Begin PBXBuildFile section */ + 2B8D25332E39A2540029FB34 /* LoginFlowFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8D25322E39A2540029FB34 /* LoginFlowFactory.swift */; }; + 2B8D25342E39A2540029FB34 /* LoginFlowFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8D25322E39A2540029FB34 /* LoginFlowFactory.swift */; }; + 2B8D25362E39A2B50029FB34 /* LoginProcessMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8D25352E39A2B50029FB34 /* LoginProcessMocks.swift */; }; + 2B8D25382E39A2D40029FB34 /* LoginProcessFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8D25372E39A2D40029FB34 /* LoginProcessFlowTests.swift */; }; 3F75522F2123D215004AC047 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3F75522E2123D214004AC047 /* Assets.xcassets */; }; 3F75523121244502004AC047 /* LoginButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F75523021244502004AC047 /* LoginButton.swift */; }; 3F946A212126D13A009914ED /* Default-568h@2x.png in Resources */ = {isa = PBXBuildFile; fileRef = 3F946A202126D13A009914ED /* Default-568h@2x.png */; }; @@ -576,6 +580,9 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 2B8D25322E39A2540029FB34 /* LoginFlowFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginFlowFactory.swift; sourceTree = ""; }; + 2B8D25352E39A2B50029FB34 /* LoginProcessMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginProcessMocks.swift; sourceTree = ""; }; + 2B8D25372E39A2D40029FB34 /* LoginProcessFlowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginProcessFlowTests.swift; sourceTree = ""; }; 3F75522E2123D214004AC047 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 3F75523021244502004AC047 /* LoginButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LoginButton.swift; sourceTree = ""; }; 3F946A202126D13A009914ED /* Default-568h@2x.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "Default-568h@2x.png"; sourceTree = ""; }; @@ -1194,6 +1201,8 @@ 4B8A9655211009A400760219 /* Login */ = { isa = PBXGroup; children = ( + 2B8D25352E39A2B50029FB34 /* LoginProcessMocks.swift */, + 2B8D25372E39A2D40029FB34 /* LoginProcessFlowTests.swift */, 4B8FF4922105C49200890AEF /* LoginFlowTests.swift */, 4B8A965621100A5800760219 /* LoginConfigurationTests.swift */, 4B792FB021102D9200EDDD1E /* LoginProcessURLResponseTests.swift */, @@ -1291,6 +1300,7 @@ 4B6508B7211812CE001796E0 /* LoginManagerOptions.swift */, 4BD33EE2238B825600C1E8A9 /* LoginManagerParameters.swift */, 4B9058DB21007C8C004D717F /* LoginResult.swift */, + 2B8D25322E39A2540029FB34 /* LoginFlowFactory.swift */, 4B45256A2101810D00A39D4F /* LoginProcess.swift */, 4B63F4722106FDCC003D1BF1 /* LoginProcessURLResponse.swift */, 4B45256E210188C300A39D4F /* LoginPermission.swift */, @@ -2371,6 +2381,7 @@ D1F20A762500BBF4005E359E /* OpenChatCreatingFormItem.swift in Sources */, 4B45256B2101810D00A39D4F /* LoginProcess.swift in Sources */, 4B9A303121210FE700174C6F /* ImageMessage.swift in Sources */, + 2B8D25332E39A2540029FB34 /* LoginFlowFactory.swift in Sources */, DBF20278212C137D00780358 /* GetApproversInGroupRequest.swift in Sources */, 4BFBEA7B211057050044C2B6 /* PostRefreshTokenRequest.swift in Sources */, ); @@ -2409,6 +2420,7 @@ 4BA8E44A210ED82B00355F03 /* AdapterTests.swift in Sources */, DB75650928CB254A001A25A5 /* UIColorExtensionTests.swift in Sources */, 4B6A0FF7212AACCF00B3ED1F /* FlexButtonComponentTests.swift in Sources */, + 2B8D25382E39A2D40029FB34 /* LoginProcessFlowTests.swift in Sources */, DB09851523D5AF9D0001A3B8 /* PKCETests.swift in Sources */, 4BBAFC502101D31300E7BFF6 /* ConstantTests.swift in Sources */, 4B9A305621215DED00174C6F /* TemplateConfirmPayloadTests.swift in Sources */, @@ -2435,6 +2447,7 @@ 4BEFF644226DCD960046DB66 /* ShareControllerTests.swift in Sources */, 4BFC09EF213CCE7700F4594D /* JWKTests.swift in Sources */, 4B3CCB9E2152449400F51D76 /* ECDSAKeyTests.swift in Sources */, + 2B8D25362E39A2B50029FB34 /* LoginProcessMocks.swift in Sources */, 4B414D5E210F077700FD19BC /* RequestStubs.swift in Sources */, 4B5EE2E8212BE0D00009DF2E /* FlexCarouselContainerTests.swift in Sources */, 4BCD27952113ECCF00B90D8F /* GetVerifyTokenRequestTests.swift in Sources */, @@ -2703,6 +2716,7 @@ 4BCD252B23FFAABA00D4B6BD /* AccessTokenVerifyResult.swift in Sources */, 4BCD253F23FFAABA00D4B6BD /* PostMultisendMessagesWithTokenRequest.swift in Sources */, 4BCD24F123FFA74100D4B6BD /* LineSDKAudioMessage.swift in Sources */, + 2B8D25342E39A2540029FB34 /* LoginFlowFactory.swift in Sources */, 4BCD256D23FFAABA00D4B6BD /* ResponsePipeline.swift in Sources */, 4BCD255A23FFAABA00D4B6BD /* FlexButtonComponent.swift in Sources */, 4BCD24F223FFA74100D4B6BD /* LineSDKFlexMessageContainer.swift in Sources */, diff --git a/LineSDK/LineSDK/Login/LoginFlowFactory.swift b/LineSDK/LineSDK/Login/LoginFlowFactory.swift new file mode 100644 index 00000000..eabed934 --- /dev/null +++ b/LineSDK/LineSDK/Login/LoginFlowFactory.swift @@ -0,0 +1,109 @@ +// +// LoginFlowFactory.swift +// +// Copyright (c) 2016-present, LY Corporation. All rights reserved. +// +// You are hereby granted a non-exclusive, worldwide, royalty-free license to use, +// copy and distribute this software in source code or binary form for use +// in connection with the web services and APIs provided by LY Corporation. +// +// As with any software that integrates with the LY Corporation platform, your use of this software +// is subject to the LINE Developers Agreement [http://terms2.line.me/LINE_Developers_Agreement]. +// This copyright notice shall be included in all copies or substantial portions of the software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import UIKit +import Foundation + +// MARK: - Application Opener Protocol + +/// Protocol to abstract UIApplication.open behavior for testing +@MainActor protocol ApplicationOpener { + func open( + _ url: URL, + options: [UIApplication.OpenExternalURLOptionsKey : Any], + completionHandler completion: (@MainActor @Sendable (Bool) -> Void)? + ) +} + +extension UIApplication: ApplicationOpener { } + +// MARK: - Flow Protocols + +/// Protocol for AppUniversalLinkFlow behavior +@MainActor +protocol AppUniversalLinkFlowType: AnyObject { + var onNext: Delegate { get } + func start() +} + +/// Protocol for AppAuthSchemeFlow behavior +@MainActor +protocol AppAuthSchemeFlowType: AnyObject { + var onNext: Delegate { get } + func start() +} + +/// Protocol for WebLoginFlow behavior +@MainActor +protocol WebLoginFlowType: AnyObject { + var onNext: Delegate { get } + var onCancel: Delegate<(), Void> { get } + func start(in viewController: UIViewController?) + func dismiss() +} + +// MARK: - LINE Availability Checker Protocol + +/// Protocol to check if LINE app is available +protocol LINEAvailabilityChecker: Sendable { + @MainActor var isLINEInstalled: Bool { get } +} + +/// Default implementation using Constant.isLINEInstalled +struct DefaultLINEAvailabilityChecker: LINEAvailabilityChecker { + @MainActor var isLINEInstalled: Bool { + return Constant.isLINEInstalled + } +} + +// MARK: - Flow Factory Protocol + +/// Factory protocol for creating login flows (allows dependency injection) +@MainActor +protocol LoginFlowFactory: Sendable { + func createAppUniversalLinkFlow(parameter: LoginProcess.FlowParameters) -> AppUniversalLinkFlowType + func createAppAuthSchemeFlow(parameter: LoginProcess.FlowParameters) -> AppAuthSchemeFlowType + func createWebLoginFlow(parameter: LoginProcess.FlowParameters) -> WebLoginFlowType +} + +// MARK: - Default Implementation + +/// Default factory implementation using real flows +@MainActor +class DefaultLoginFlowFactory: LoginFlowFactory { + private let applicationOpener: ApplicationOpener + + init(applicationOpener: ApplicationOpener = UIApplication.shared) { + self.applicationOpener = applicationOpener + } + + func createAppUniversalLinkFlow(parameter: LoginProcess.FlowParameters) -> AppUniversalLinkFlowType { + return AppUniversalLinkFlow(parameter: parameter, applicationOpener: applicationOpener) + } + + func createAppAuthSchemeFlow(parameter: LoginProcess.FlowParameters) -> AppAuthSchemeFlowType { + return AppAuthSchemeFlow(parameter: parameter, applicationOpener: applicationOpener) + } + + func createWebLoginFlow(parameter: LoginProcess.FlowParameters) -> WebLoginFlowType { + return WebLoginFlow(parameter: parameter) + } +} diff --git a/LineSDK/LineSDK/Login/LoginProcess.swift b/LineSDK/LineSDK/Login/LoginProcess.swift index 77386d23..d47f5557 100644 --- a/LineSDK/LineSDK/Login/LoginProcess.swift +++ b/LineSDK/LineSDK/Login/LoginProcess.swift @@ -62,9 +62,9 @@ public class LoginProcess { loginParameter.promptBotID } } - + /// Observes application switching to foreground. - /// + /// /// If the app switching happens during login process, we want to /// inspect the event of switched back from another app (Safari or LINE or any other) /// If the framework container app has not been started up by an `open(url:)`, we think current @@ -74,15 +74,15 @@ public class LoginProcess { // A token holds current observing. It will be released and trigger remove observer // when this `AppSwitchingObserver` gets released. var token: NotificationToken? - + // Controls whether we really need the trigger. By setting this to `false`, `onTrigger` will not be // called even a `.UIApplicationDidBecomeActive` event received. var valid: Bool = true - + let onTrigger = Delegate<(), Void>() - + init() { } - + func startObserving() { token = NotificationCenter.default .addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) @@ -96,16 +96,18 @@ public class LoginProcess { } } } - + let configuration: LoginConfiguration let scopes: Set let parameters: LoginManager.Parameters + private let flowFactory: LoginFlowFactory + private let lineAvailabilityChecker: LINEAvailabilityChecker // Flows of login process. A flow will be `nil` until it is running, so we could tell which one should take // responsibility to handle a url callback response. - + // LINE Client app auth flow captured by LINE universal link. - var appUniversalLinkFlow: AppUniversalLinkFlow? { + var appUniversalLinkFlow: AppUniversalLinkFlowType? { didSet { if appUniversalLinkFlow != nil && loginRoute == nil { loginRoute = .appUniversalLink @@ -113,7 +115,7 @@ public class LoginProcess { } } // LINE Client app auth flow by LINE customize URL scheme. - var appAuthSchemeFlow: AppAuthSchemeFlow? { + var appAuthSchemeFlow: AppAuthSchemeFlowType? { didSet { if appAuthSchemeFlow != nil && loginRoute == nil { loginRoute = .appAuthScheme @@ -122,7 +124,7 @@ public class LoginProcess { } // Web login flow with Safari View Controller or Mobile Safari - var webLoginFlow: WebLoginFlow? { + var webLoginFlow: WebLoginFlowType? { didSet { // Dismiss safari view controller (if exists) when reset web login flow. if webLoginFlow == nil { @@ -149,40 +151,48 @@ public class LoginProcess { // When we leave current app, we need to set the switching observer // to intercept cancel event (switching back but without a token url response) var appSwitchingObserver: AppSwitchingObserver? - + weak var presentingViewController: UIViewController? - + /// A random piece of data for current process. Used to verify with server `state` response. let processID: String - + /// A string used to prevent replay attacks. This value will be returned in an ID token. let IDTokenNonce: String? - + let pkce: PKCE let onSucceed = Delegate<(token: AccessToken, response: LoginProcessURLResponse), Void>() let onFail = Delegate() - + init( configuration: LoginConfiguration, scopes: Set, parameters: LoginManager.Parameters, - viewController: UIViewController?) - { + viewController: UIViewController?, + // This should be able to be a non-nil value with DefaultLoginFlowFactory() as its default value. But + // CocaoPods lint does not like when Swift 4.2 language version is used. + // Revert this later when we can drop support for Swift 4.2: + // flowFactory: LoginFlowFactory = DefaultLoginFlowFactory(), + flowFactory: LoginFlowFactory? = nil, + lineAvailabilityChecker: LINEAvailabilityChecker = DefaultLINEAvailabilityChecker() + ) { self.configuration = configuration self.processID = Data.randomData(bytesCount: 32).base64URLEncoded self.pkce = PKCE() self.scopes = scopes self.parameters = parameters self.presentingViewController = viewController - + self.flowFactory = flowFactory ?? DefaultLoginFlowFactory() + self.lineAvailabilityChecker = lineAvailabilityChecker + if scopes.contains(.openID) { IDTokenNonce = self.parameters.IDTokenNonce ?? Data.randomData(bytesCount: 32).base64URLEncoded } else { IDTokenNonce = nil } } - + func start() { let parameters = FlowParameters( channelID: configuration.channelID, @@ -204,12 +214,15 @@ public class LoginProcess { } #endif } - + /// Stops the login process. The login process will fail with a `.forceStopped` error. public func stop() { invokeFailure(error: LineSDKError.authorizeFailed(reason: .forceStopped)) + loginRoute = nil + appSwitchingObserver?.valid = false + appSwitchingObserver = nil } - + // App switching observer should only work when external app switching happens during login process. // That means, we should not call this when login with SFSafariViewController. private func setupAppSwitchingObserver() { @@ -223,12 +236,12 @@ public class LoginProcess { } } appSwitchingObserver = observer - + observer.startObserving() } - + private func startAppUniversalLinkFlow(_ parameters: FlowParameters) { - let appUniversalLinkFlow = AppUniversalLinkFlow(parameter: parameters) + let appUniversalLinkFlow = flowFactory.createAppUniversalLinkFlow(parameter: parameters) appUniversalLinkFlow.onNext.delegate(on: self) { [unowned appUniversalLinkFlow] (self, started) in // Can handle app universal link flow. Store the flow for later resuming use. if started { @@ -243,12 +256,12 @@ public class LoginProcess { } } } - + appUniversalLinkFlow.start() } - + private func startAppAuthSchemeFlow(_ parameters: FlowParameters) { - let appAuthSchemeFlow = AppAuthSchemeFlow(parameter: parameters) + let appAuthSchemeFlow = flowFactory.createAppAuthSchemeFlow(parameter: parameters) appAuthSchemeFlow.onNext.delegate(on: self) { [unowned appAuthSchemeFlow] (self, started) in if started { self.setupAppSwitchingObserver() @@ -257,12 +270,12 @@ public class LoginProcess { self.startWebLoginFlow(parameters) } } - + appAuthSchemeFlow.start() } - + private func startWebLoginFlow(_ parameters: FlowParameters) { - let webLoginFlow = WebLoginFlow(parameter: parameters) + let webLoginFlow = flowFactory.createWebLoginFlow(parameter: parameters) webLoginFlow.onNext.delegate(on: self) { [unowned webLoginFlow] (self, result) in switch result { case .safariViewController: @@ -277,7 +290,7 @@ public class LoginProcess { webLoginFlow.onCancel.delegate(on: self) { (self, _) in self.invokeFailure(error: LineSDKError.authorizeFailed(reason: .userCancelled)) } - + webLoginFlow.start(in: presentingViewController) } @@ -292,10 +305,10 @@ public class LoginProcess { invokeFailure(error: LineSDKError.authorizeFailed(reason: .callbackURLSchemeNotMatching)) return false } - + // It is the callback url we could handle, so the app switching observer should be invalidated. appSwitchingObserver?.valid = false - + // Wait for a while before request access token. // // When switching back to SDK container app from another app, with url scheme or universal link, @@ -390,22 +403,22 @@ public class LoginProcess { } } } - + private var canUseLineAuthV2: Bool { - return Constant.isLINEInstalled + return lineAvailabilityChecker.isLINEInstalled } - + private func resetFlows() { appUniversalLinkFlow = nil appAuthSchemeFlow = nil webLoginFlow = nil } - + private func invokeSuccess(result: AccessToken, response: LoginProcessURLResponse) { resetFlows() onSucceed.call((result, response)) } - + private func invokeFailure(error: Error) { resetFlows() onFail.call(error) @@ -413,18 +426,20 @@ public class LoginProcess { } @MainActor -class AppUniversalLinkFlow { - +class AppUniversalLinkFlow: AppUniversalLinkFlowType { + let url: URL let onNext = Delegate() - - init(parameter: LoginProcess.FlowParameters) { + private let applicationOpener: ApplicationOpener + + init(parameter: LoginProcess.FlowParameters, applicationOpener: ApplicationOpener = UIApplication.shared) { let universalURLBase = URL(string: Constant.lineWebAuthUniversalURL)! url = universalURLBase.appendedLoginQuery(parameter) + self.applicationOpener = applicationOpener } - + func start() { - UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { + applicationOpener.open(url, options: [.universalLinksOnly: true]) { opened in self.onNext.call(opened) } @@ -432,17 +447,19 @@ class AppUniversalLinkFlow { } @MainActor -class AppAuthSchemeFlow { - +class AppAuthSchemeFlow: AppAuthSchemeFlowType { + let url: URL let onNext = Delegate() - - init(parameter: LoginProcess.FlowParameters) { + private let applicationOpener: ApplicationOpener + + init(parameter: LoginProcess.FlowParameters, applicationOpener: ApplicationOpener = UIApplication.shared) { url = Constant.lineAppAuthURLv2.appendedURLSchemeQuery(parameter) + self.applicationOpener = applicationOpener } - + func start() { - UIApplication.shared.open(url, options: [:]) { + applicationOpener.open(url, options: [:]) { opened in self.onNext.call(opened) } @@ -450,19 +467,19 @@ class AppAuthSchemeFlow { } @MainActor -class WebLoginFlow: NSObject { - +class WebLoginFlow: NSObject, WebLoginFlowType { + enum Next { case safariViewController case error(Error) } - + let url: URL let onNext = Delegate() let onCancel = Delegate<(), Void>() - + weak var safariViewController: UIViewController? - + init(parameter: LoginProcess.FlowParameters) { var component = URLComponents(string: Constant.lineWebAuthURL)! if parameter.loginParameter.initialWebAuthenticationMethod == .qrCode { @@ -474,7 +491,7 @@ class WebLoginFlow: NSObject { let baseURL = component.url! url = baseURL.appendedLoginQuery(parameter) } - + func start(in viewController: UIViewController?) { let safariViewController = SFSafariViewController(url: url) safariViewController.modalPresentationStyle = .overFullScreen @@ -483,7 +500,7 @@ class WebLoginFlow: NSObject { safariViewController.dismissButtonStyle = .cancel self.safariViewController = safariViewController - + guard let presenting = viewController ?? .topMost else { self.onNext.call(.error(LineSDKError.authorizeFailed(reason: .malformedHierarchy))) return @@ -492,7 +509,7 @@ class WebLoginFlow: NSObject { self.onNext.call(.safariViewController) } } - + func dismiss() { self.safariViewController?.dismiss(animated: true) } @@ -511,9 +528,9 @@ extension WebLoginFlow: @preconcurrency SFSafariViewControllerDelegate { // Helpers for creating urls for login process extension String { - + static func returnUri(_ parameter: LoginProcess.FlowParameters) -> String { - + var parameters: [String: Any] = [ "response_type": "code", "sdk_ver": Constant.SDKVersion, @@ -524,7 +541,7 @@ extension String { "state": parameter.processID, "redirect_uri": Constant.thirdPartyAppReturnURL, ] - + if let url = parameter.universalLinkURL { parameters["optional_redirect_uri"] = url.absoluteString } @@ -560,7 +577,7 @@ extension URL { let encoder = URLQueryEncoder(parameters: parameters) return encoder.encoded(for: self) } - + func appendedURLSchemeQuery(_ flowParameters: LoginProcess.FlowParameters) -> URL { let loginBase = URL(string: Constant.lineWebAuthUniversalURL)! let loginUrl = loginBase.appendedLoginQuery(flowParameters) @@ -578,7 +595,7 @@ extension UIWindow { // A key window of main app exists, go ahead and use it return window } - + // Otherwise, try to find a normal level window let window = UIApplication.shared.windows.first { $0.windowLevel == .normal } guard let result = window else { @@ -602,11 +619,11 @@ extension UIViewController { "Please check your view controller hierarchy.") return nil } - + while let currentTop = topViewController.presentedViewController { topViewController = currentTop } - + return topViewController } } diff --git a/LineSDK/LineSDKTests/Login/LoginFlowTests.swift b/LineSDK/LineSDKTests/Login/LoginFlowTests.swift index e1acfd38..09cb2642 100644 --- a/LineSDK/LineSDKTests/Login/LoginFlowTests.swift +++ b/LineSDK/LineSDKTests/Login/LoginFlowTests.swift @@ -219,7 +219,7 @@ class LoginFlowTests: XCTestCase, ViewControllerCompatibleTest { func testAppUniversalLinkFlow() { let expect = expectation(description: "\(#file)_\(#line)") - let flow = AppUniversalLinkFlow(parameter: parameter) + let flow = AppUniversalLinkFlow(parameter: parameter, applicationOpener: UIApplication.shared) let universal = URL(string: Constant.lineWebAuthUniversalURL)! let components = URLComponents(url: flow.url, resolvingAgainstBaseURL: false) XCTAssertEqual(components?.scheme, "https") @@ -236,7 +236,7 @@ class LoginFlowTests: XCTestCase, ViewControllerCompatibleTest { func testAppAuthSchemeFlow() { let expect = expectation(description: "\(#file)_\(#line)") - let flow = AppAuthSchemeFlow(parameter: parameter) + let flow = AppAuthSchemeFlow(parameter: parameter, applicationOpener: UIApplication.shared) let components = URLComponents(url: flow.url, resolvingAgainstBaseURL: false) XCTAssertEqual(components?.scheme, Constant.lineAuthV2Scheme) XCTAssertEqual(components?.host, "authorize") @@ -347,6 +347,102 @@ class LoginFlowTests: XCTestCase, ViewControllerCompatibleTest { } waitForExpectations(timeout: 1.0, handler: nil) } + + // MARK: - Window Management Tests + + func testFindKeyWindowWithCreatedWindow() { + let testWindow = UIWindow(frame: UIScreen.main.bounds) + testWindow.windowLevel = .normal + testWindow.makeKeyAndVisible() + + let foundWindow = UIWindow.findKeyWindow() + XCTAssertNotNil(foundWindow, "Should find the created window") + XCTAssertEqual(foundWindow, testWindow, "Should return the test window") + XCTAssertEqual(foundWindow?.windowLevel, .normal, "Window should be at normal level") + XCTAssertTrue(foundWindow?.isKeyWindow == true, "Found window should be key window") + + testWindow.isHidden = true + } + + func testTopMostViewControllerWithSetup() { + let rootViewController = setupViewController() + + let topMost = UIViewController.topMost + XCTAssertNotNil(topMost, "Should find top most view controller with setup window") + XCTAssertEqual(topMost, rootViewController, "Top most should be the root view controller") + + resetViewController() + } + + func testTopMostViewControllerWithPresentation() { + let rootViewController = setupViewController() + let presentedViewController = UIViewController() + + let expectPresent = expectation(description: "present") + rootViewController.present(presentedViewController, animated: false) { + expectPresent.fulfill() + } + wait(for: [expectPresent], timeout: 1.0) + + let topMost = UIViewController.topMost + XCTAssertNotNil(topMost, "Should find top most view controller") + XCTAssertEqual(topMost, presentedViewController, "Top most should be the presented view controller") + + let expectDismiss = expectation(description: "dismiss") + presentedViewController.dismiss(animated: false) { + expectDismiss.fulfill() + } + wait(for: [expectDismiss], timeout: 1.0) + + resetViewController() + } + + func testTopMostViewControllerWithNestedPresentation() { + let rootViewController = setupViewController() + let firstPresentedViewController = UIViewController() + let secondPresentedViewController = UIViewController() + + let expectFirstPresent = expectation(description: "first present") + rootViewController.present(firstPresentedViewController, animated: false) { + expectFirstPresent.fulfill() + } + wait(for: [expectFirstPresent], timeout: 1.0) + + let expectSecondPresent = expectation(description: "second present") + firstPresentedViewController.present(secondPresentedViewController, animated: false) { + expectSecondPresent.fulfill() + } + wait(for: [expectSecondPresent], timeout: 1.0) + + let topMost = UIViewController.topMost + XCTAssertNotNil(topMost, "Should find top most view controller") + XCTAssertEqual(topMost, secondPresentedViewController, "Top most should be the deepest presented view controller") + + let expectDismissSecond = expectation(description: "dismiss second") + secondPresentedViewController.dismiss(animated: false) { + expectDismissSecond.fulfill() + } + wait(for: [expectDismissSecond], timeout: 1.0) + + let expectDismissFirst = expectation(description: "dismiss first") + firstPresentedViewController.dismiss(animated: false) { + expectDismissFirst.fulfill() + } + wait(for: [expectDismissFirst], timeout: 1.0) + + resetViewController() + } + + func testWindowWithoutRootViewController() { + let testWindow = UIWindow(frame: UIScreen.main.bounds) + testWindow.windowLevel = .normal + testWindow.makeKeyAndVisible() + + let topMost = UIViewController.topMost + XCTAssertNil(topMost, "Should return nil when window has no root view controller") + + testWindow.isHidden = true + } } diff --git a/LineSDK/LineSDKTests/Login/LoginManagerTests.swift b/LineSDK/LineSDKTests/Login/LoginManagerTests.swift index bf9d17a7..507a29b0 100644 --- a/LineSDK/LineSDKTests/Login/LoginManagerTests.swift +++ b/LineSDK/LineSDKTests/Login/LoginManagerTests.swift @@ -37,30 +37,7 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest { var window: UIWindow! - override func setUp() { - let url = URL(string: "https://example.com/auth") - LoginManager.shared.setup(channelID: "123", universalLinkURL: url) - } - - override func tearDown() async throws { - LoginManager.shared.reset() - resetViewController() - } - - func testSetupLoginManager() { - XCTAssertNotNil(Session.shared) - XCTAssertNotNil(AccessTokenStore.shared) - XCTAssertNotNil(LoginConfiguration.shared) - - XCTAssertTrue(LoginManager.shared.isSetupFinished) - } - - func testLoginAction() { - let expect = expectation(description: "\(#file)_\(#line)") - - XCTAssertFalse(LoginManager.shared.isAuthorized) - XCTAssertFalse(LoginManager.shared.isAuthorizing) - + private func setupSessionStub() { let delegateStub = SessionDelegateStub(stubs: [ .init(data: PostExchangeTokenRequest.successData, responseCode: 200), .init(data: GetUserProfileRequest.successData, responseCode: 200) @@ -69,9 +46,23 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest { configuration: LoginConfiguration.shared, delegate: delegateStub ) + } + + private func performLoginTest( + permissions: Set, + expectOpenID: Bool, + useNonIsolatedResumeURL: Bool = false, + additionalAssertions: ((LoginResult, LoginProcess) -> Void)? = nil + ) { + let expect = expectation(description: "\(#file)_\(#line)") + + XCTAssertFalse(LoginManager.shared.isAuthorized) + XCTAssertFalse(LoginManager.shared.isAuthorizing) + + setupSessionStub() var process: LoginProcess! - process = LoginManager.shared.login(permissions: [.profile], in: setupViewController()) { + process = LoginManager.shared.login(permissions: permissions, in: setupViewController()) { loginResult in XCTAssertNotNil(loginResult.value) @@ -82,75 +73,86 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest { XCTAssertTrue(LoginManager.shared.isAuthorized) XCTAssertFalse(LoginManager.shared.isAuthorizing) - // IDTokenNonce should be `nil` when `.openID` not required. - XCTAssertNil(result.IDTokenNonce) + if expectOpenID { + XCTAssertNotNil(result.IDTokenNonce) + XCTAssertEqual(result.IDTokenNonce, process!.IDTokenNonce) + } else { + XCTAssertNil(result.IDTokenNonce) + } - XCTAssertEqual(process.loginRoute, .appUniversalLink) + additionalAssertions?(result, process!) try! AccessTokenStore.shared.removeCurrentAccessToken() expect.fulfill() }! - // Set a sample value for checking `loginRoute` in the result. - process.appUniversalLinkFlow = AppUniversalLinkFlow(parameter: sampleFlowParameters) + if !expectOpenID { + process.appUniversalLinkFlow = AppUniversalLinkFlow(parameter: sampleFlowParameters) + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { XCTAssertFalse(LoginManager.shared.isAuthorized) XCTAssertTrue(LoginManager.shared.isAuthorizing) - // Simulate auth result let urlString = "\(Constant.thirdPartyAppReturnURL)?code=123&state=\(process.processID)" - let handled = process.resumeOpenURL(url: URL(string: urlString)!) + let handled: Bool + if useNonIsolatedResumeURL { + handled = process.nonisolatedResumeOpenURL(url: URL(string: urlString)!) + } else { + handled = process.resumeOpenURL(url: URL(string: urlString)!) + } XCTAssertTrue(handled) } waitForExpectations(timeout: 2, handler: nil) } + + override func setUp() { + let url = URL(string: "https://example.com/auth") + LoginManager.shared.setup(channelID: "123", universalLinkURL: url) + } + + override func tearDown() async throws { + LoginManager.shared.reset() + resetViewController() + } + + func testSetupLoginManager() { + XCTAssertNotNil(Session.shared) + XCTAssertNotNil(AccessTokenStore.shared) + XCTAssertNotNil(LoginConfiguration.shared) + + XCTAssertTrue(LoginManager.shared.isSetupFinished) + } + + func testLoginAction() { + performLoginTest( + permissions: [.profile], + expectOpenID: false + ) { result, process in + XCTAssertEqual(process.loginRoute, .appUniversalLink) + } + } func testLoginActionWithOpenID() { - let expect = expectation(description: "\(#file)_\(#line)") - - XCTAssertFalse(LoginManager.shared.isAuthorized) - XCTAssertFalse(LoginManager.shared.isAuthorizing) - - let delegateStub = SessionDelegateStub(stubs: [ - .init(data: PostExchangeTokenRequest.successData, responseCode: 200), - .init(data: GetUserProfileRequest.successData, responseCode: 200) - ]) - Session._shared = Session( - configuration: LoginConfiguration.shared, - delegate: delegateStub + performLoginTest( + permissions: [.profile, .openID], + expectOpenID: true ) + } - var process: LoginProcess! - process = LoginManager.shared.login(permissions: [.profile, .openID], in: setupViewController()) { - loginResult in - XCTAssertNotNil(loginResult.value) - - let result = loginResult.value! - - // IDTokenNonce should be `nil` when `.openID` not required. - XCTAssertNotNil(result.IDTokenNonce) - XCTAssertEqual(result.IDTokenNonce, process!.IDTokenNonce) - - try! AccessTokenStore.shared.removeCurrentAccessToken() - expect.fulfill() - }! - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - - XCTAssertFalse(LoginManager.shared.isAuthorized) - XCTAssertTrue(LoginManager.shared.isAuthorizing) - - let urlString = "\(Constant.thirdPartyAppReturnURL)?code=123&state=\(process.processID)" - let handled = process.resumeOpenURL(url: URL(string: urlString)!) - XCTAssertTrue(handled) + func testLoginActionWithNonIsolatedResumeOpenURL() { + performLoginTest( + permissions: [.profile], + expectOpenID: false, + useNonIsolatedResumeURL: true + ) { result, process in + XCTAssertEqual(process.loginRoute, .appUniversalLink) } - - waitForExpectations(timeout: 2, handler: nil) } - + + func testLogout() { let expect = expectation(description: "\(#file)_\(#line)") @@ -217,6 +219,131 @@ class LoginManagerTests: XCTestCase, ViewControllerCompatibleTest { XCTAssertEqual(process.loginRoute, .webLogin) } } + + // MARK: - Token Exchange Error Tests + + func testExchangeTokenWithNonNetworkError() { + let expect = expectation(description: "\(#file)_\(#line)") + + // Use a simple NSError that's not a network connection lost error + let customError = NSError(domain: "TestDomain", code: 999, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + + let delegateStub = SessionDelegateStub(stub: .error(customError)) + Session._shared = Session( + configuration: LoginConfiguration.shared, + delegate: delegateStub + ) + + var process: LoginProcess! + process = LoginManager.shared.login(permissions: [.profile], in: setupViewController()) { + loginResult in + XCTAssertNotNil(loginResult.error) + XCTAssertNil(loginResult.value) + + if let error = loginResult.error { + // Should receive the error wrapped in URLSessionError but not be a network connection lost error + XCTAssertFalse(error.isURLSessionErrorCode(sessionErrorCode: NSURLErrorNetworkConnectionLost)) + } else { + XCTFail("Should receive LineSDKError, but got: \(String(describing: loginResult.error))") + } + + expect.fulfill() + }! + + process.appUniversalLinkFlow = AppUniversalLinkFlow(parameter: sampleFlowParameters) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let urlString = "\(Constant.thirdPartyAppReturnURL)?code=123&state=\(process.processID)" + let handled = process.resumeOpenURL(url: URL(string: urlString)!) + XCTAssertTrue(handled) + } + + waitForExpectations(timeout: 5, handler: nil) + } + + func testExchangeTokenWithNetworkErrorRetrySuccess() { + let expect = expectation(description: "\(#file)_\(#line)") + + let networkError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost, userInfo: nil) + + // For retry scenario: + // 1. First PostExchangeTokenRequest -> network error + // 2. Second PostExchangeTokenRequest (retry) -> success + // 3. GetUserProfileRequest -> success + let delegateStub = SessionDelegateStub(stubs: [ + .error(networkError), + .init(data: PostExchangeTokenRequest.successData, responseCode: 200), + .init(data: GetUserProfileRequest.successData, responseCode: 200) + ]) + Session._shared = Session( + configuration: LoginConfiguration.shared, + delegate: delegateStub + ) + + var process: LoginProcess! + process = LoginManager.shared.login(permissions: [.profile], in: setupViewController()) { + loginResult in + if let error = loginResult.error { + XCTFail("Should succeed after retry, but got error: \(error)") + } else if let result = loginResult.value { + XCTAssertEqual(result.accessToken.value, PostExchangeTokenRequest.successToken) + try! AccessTokenStore.shared.removeCurrentAccessToken() + } else { + XCTFail("No result received") + } + expect.fulfill() + }! + + process.appUniversalLinkFlow = AppUniversalLinkFlow(parameter: sampleFlowParameters) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let urlString = "\(Constant.thirdPartyAppReturnURL)?code=123&state=\(process.processID)" + let handled = process.resumeOpenURL(url: URL(string: urlString)!) + XCTAssertTrue(handled) + } + + waitForExpectations(timeout: 10, handler: nil) + } + + func testExchangeTokenWithNetworkErrorRetryFail() { + let expect = expectation(description: "\(#file)_\(#line)") + + let networkError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNetworkConnectionLost, userInfo: nil) + + let delegateStub = SessionDelegateStub(stubs: [ + .error(networkError), + .error(networkError) + ]) + Session._shared = Session( + configuration: LoginConfiguration.shared, + delegate: delegateStub + ) + + var process: LoginProcess! + process = LoginManager.shared.login(permissions: [.profile], in: setupViewController()) { + loginResult in + XCTAssertNotNil(loginResult.error) + XCTAssertNil(loginResult.value) + + if let error = loginResult.error { + XCTAssertTrue(error.isURLSessionErrorCode(sessionErrorCode: NSURLErrorNetworkConnectionLost)) + } else { + XCTFail("Should receive network connection lost error") + } + + expect.fulfill() + }! + + process.appUniversalLinkFlow = AppUniversalLinkFlow(parameter: sampleFlowParameters) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + let urlString = "\(Constant.thirdPartyAppReturnURL)?code=123&state=\(process.processID)" + let handled = process.resumeOpenURL(url: URL(string: urlString)!) + XCTAssertTrue(handled) + } + + waitForExpectations(timeout: 5, handler: nil) + } } diff --git a/LineSDK/LineSDKTests/Login/LoginProcessFlowTests.swift b/LineSDK/LineSDKTests/Login/LoginProcessFlowTests.swift new file mode 100644 index 00000000..cf1de680 --- /dev/null +++ b/LineSDK/LineSDKTests/Login/LoginProcessFlowTests.swift @@ -0,0 +1,428 @@ +// +// LoginProcessFlowTests.swift +// +// Copyright (c) 2016-present, LY Corporation. All rights reserved. +// +// You are hereby granted a non-exclusive, worldwide, royalty-free license to use, +// copy and distribute this software in source code or binary form for use +// in connection with the web services and APIs provided by LY Corporation. +// +// As with any software that integrates with the LY Corporation platform, your use of this software +// is subject to the LINE Developers Agreement [http://terms2.line.me/LINE_Developers_Agreement]. +// This copyright notice shall be included in all copies or substantial portions of the software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import XCTest +@testable import LineSDK + +@MainActor +class LoginProcessFlowTests: XCTestCase, ViewControllerCompatibleTest { + + var window: UIWindow! + let mockFlowFactory = MockLoginFlowFactory() + + // MARK: - Helper Methods + + private func createLoginProcess( + lineInstalled: Bool = true, + onlyWebLogin: Bool = false + ) -> LoginProcess { + let mockLineChecker = MockLINEAvailabilityChecker(isLINEInstalled: lineInstalled) + + var parameters = LoginManager.Parameters() + parameters.onlyWebLogin = onlyWebLogin + + return LoginProcess( + configuration: LoginConfiguration(channelID: "test_channel", universalLinkURL: nil), + scopes: [.profile], + parameters: parameters, + viewController: setupViewController(), + flowFactory: mockFlowFactory, + lineAvailabilityChecker: mockLineChecker + ) + } + + // MARK: - Universal Link Flow Tests + + func testUniversalLinkFlowSuccess() { + let expect = expectation(description: "universal link success") + + let process = createLoginProcess() + mockFlowFactory.configureAllFlowsToSucceed() + + // We cannot directly test setupAppSwitchingObserver since it's private, + // but we can verify the observer property is set + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // Verify Universal Link flow was created and started + XCTAssertNotNil(self.mockFlowFactory.mockUniversalLinkFlow) + XCTAssertEqual(self.mockFlowFactory.mockUniversalLinkFlow?.startCallCount, 1) + + // Verify that flow is set and app switching observer should be called + XCTAssertNotNil(process.appUniversalLinkFlow) + XCTAssertEqual(process.loginRoute, .appUniversalLink) + + // Auth scheme and web login should not be started + XCTAssertNil(self.mockFlowFactory.mockAuthSchemeFlow) + XCTAssertNil(self.mockFlowFactory.mockWebLoginFlow) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testUniversalLinkFlowFailureFallbackToAuth() { + let expect = expectation(description: "universal link failure fallback to auth") + + let process = createLoginProcess() + mockFlowFactory.configureUniversalLinkFailAuthSchemeSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // Verify Universal Link flow was started and failed + XCTAssertNotNil(self.mockFlowFactory.mockUniversalLinkFlow) + XCTAssertEqual(self.mockFlowFactory.mockUniversalLinkFlow?.startCallCount, 1) + + // Verify Auth Scheme flow was started as fallback + XCTAssertNotNil(self.mockFlowFactory.mockAuthSchemeFlow) + XCTAssertEqual(self.mockFlowFactory.mockAuthSchemeFlow?.startCallCount, 1) + XCTAssertNotNil(process.appAuthSchemeFlow) + XCTAssertEqual(process.loginRoute, .appAuthScheme) + + // Web login should not be started + XCTAssertNil(self.mockFlowFactory.mockWebLoginFlow) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testUniversalLinkFlowFailureFallbackToWeb() { + let expect = expectation(description: "universal link failure fallback to web") + + mockFlowFactory.configureUniversalLinkFailAuthSchemeSucceed() + let process = createLoginProcess(lineInstalled: false) // LINE not installed + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // Verify Universal Link flow was started and failed + XCTAssertNotNil(self.mockFlowFactory.mockUniversalLinkFlow) + XCTAssertEqual(self.mockFlowFactory.mockUniversalLinkFlow?.startCallCount, 1) + + // Auth Scheme should be skipped since LINE is not installed + XCTAssertNil(self.mockFlowFactory.mockAuthSchemeFlow) + + // Verify Web Login flow was started as fallback + XCTAssertNotNil(self.mockFlowFactory.mockWebLoginFlow) + XCTAssertEqual(self.mockFlowFactory.mockWebLoginFlow?.startCallCount, 1) + XCTAssertNotNil(process.webLoginFlow) + XCTAssertEqual(process.loginRoute, .webLogin) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + // MARK: - Auth Scheme Flow Tests + + func testAuthSchemeFlowSuccess() { + let expect = expectation(description: "auth scheme success") + + mockFlowFactory.universalLinkShouldSucceed = false + mockFlowFactory.authSchemeShouldSucceed = true + let process = createLoginProcess(lineInstalled: true) + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // Verify Auth Scheme flow was started and succeeded + XCTAssertNotNil(self.mockFlowFactory.mockAuthSchemeFlow) + XCTAssertEqual(self.mockFlowFactory.mockAuthSchemeFlow?.startCallCount, 1) + XCTAssertNotNil(process.appAuthSchemeFlow) + XCTAssertEqual(process.loginRoute, .appAuthScheme) + + // Web login should not be started + XCTAssertNil(self.mockFlowFactory.mockWebLoginFlow) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testAuthSchemeFlowFailureFallbackToWeb() { + let expect = expectation(description: "auth scheme failure fallback to web") + + let process = createLoginProcess() + mockFlowFactory.configureAllAppFlowsFailWebSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // Both Universal Link and Auth Scheme should have been tried + XCTAssertNotNil(self.mockFlowFactory.mockUniversalLinkFlow) + XCTAssertNotNil(self.mockFlowFactory.mockAuthSchemeFlow) + + // Web Login should be started as final fallback + XCTAssertNotNil(self.mockFlowFactory.mockWebLoginFlow) + XCTAssertEqual(self.mockFlowFactory.mockWebLoginFlow?.startCallCount, 1) + XCTAssertNotNil(process.webLoginFlow) + XCTAssertEqual(process.loginRoute, .webLogin) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + // MARK: - Web Login Flow Tests + + func testWebLoginFlowSuccess() { + let expect = expectation(description: "web login success") + + let process = createLoginProcess() + mockFlowFactory.configureAllAppFlowsFailWebSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // Verify Web Login flow was started and succeeded + XCTAssertNotNil(self.mockFlowFactory.mockWebLoginFlow) + XCTAssertEqual(self.mockFlowFactory.mockWebLoginFlow?.startCallCount, 1) + XCTAssertNotNil(process.webLoginFlow) + XCTAssertEqual(process.loginRoute, .webLogin) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testWebLoginFlowError() { + let expect = expectation(description: "web login error") + + let process = createLoginProcess() + mockFlowFactory.configureAllFlowsToFail() + + var receivedError: Error? + process.onFail.delegate(on: self) { (self, error) in + receivedError = error + expect.fulfill() + } + + process.start() + + waitForExpectations(timeout: 1.0) + + XCTAssertNotNil(receivedError) + XCTAssertTrue(receivedError is LineSDKError) + } + + // MARK: - Complete Flow Chain Tests + + func testCompleteFlowChain() { + let expect = expectation(description: "complete flow chain") + + // Configure all flows to fail except web login + let process = createLoginProcess() + mockFlowFactory.configureAllAppFlowsFailWebSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + // Verify all flows were attempted in order + XCTAssertNotNil(self.mockFlowFactory.mockUniversalLinkFlow) + XCTAssertEqual(self.mockFlowFactory.mockUniversalLinkFlow?.startCallCount, 1) + + XCTAssertNotNil(self.mockFlowFactory.mockAuthSchemeFlow) + XCTAssertEqual(self.mockFlowFactory.mockAuthSchemeFlow?.startCallCount, 1) + + XCTAssertNotNil(self.mockFlowFactory.mockWebLoginFlow) + XCTAssertEqual(self.mockFlowFactory.mockWebLoginFlow?.startCallCount, 1) + + // Final state should be web login + XCTAssertEqual(process.loginRoute, .webLogin) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testOnlyWebLoginParameter() { + let expect = expectation(description: "only web login parameter") + + let process = createLoginProcess(onlyWebLogin: true) + mockFlowFactory.configureAllFlowsToSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // Only Web Login should be started, no app links + XCTAssertNil(self.mockFlowFactory.mockUniversalLinkFlow) + XCTAssertNil(self.mockFlowFactory.mockAuthSchemeFlow) + + XCTAssertNotNil(self.mockFlowFactory.mockWebLoginFlow) + XCTAssertEqual(self.mockFlowFactory.mockWebLoginFlow?.startCallCount, 1) + XCTAssertEqual(process.loginRoute, .webLogin) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + // MARK: - App Switching Observer Tests + + func testAppSwitchingObserverSetupOnUniversalLinkSuccess() { + let expect = expectation(description: "app switching observer setup on universal link success") + + let process = createLoginProcess() + mockFlowFactory.configureAllFlowsToSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // When Universal Link succeeds, app switching observer should be set up + XCTAssertNotNil(process.appSwitchingObserver) + XCTAssertEqual(process.loginRoute, .appUniversalLink) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testAppSwitchingObserverSetupOnAuthSchemeSuccess() { + let expect = expectation(description: "app switching observer setup on auth scheme success") + + let process = createLoginProcess() + mockFlowFactory.configureUniversalLinkFailAuthSchemeSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + // When Auth Scheme succeeds, app switching observer should be set up + XCTAssertNotNil(process.appSwitchingObserver) + XCTAssertEqual(process.loginRoute, .appAuthScheme) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + func testAppSwitchingObserverNotSetupOnWebLogin() { + let expect = expectation(description: "app switching observer not setup on web login") + + let process = createLoginProcess() + mockFlowFactory.configureAllAppFlowsFailWebSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + // When only Web Login is used, no app switching observer should be set up + // because we don't leave the app + XCTAssertNil(process.appSwitchingObserver) + XCTAssertEqual(process.loginRoute, .webLogin) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + + // MARK: - MacCatalyst Behavior Test + + #if targetEnvironment(macCatalyst) + func testMacCatalystBehavior() { + let expect = expectation(description: "macCatalyst behavior") + + let process = createLoginProcess() + mockFlowFactory.configureAllFlowsToSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // On macCatalyst, should skip to Web Login directly + XCTAssertNil(self.mockFlowFactory.mockUniversalLinkFlow) + XCTAssertNil(self.mockFlowFactory.mockAuthSchemeFlow) + + XCTAssertNotNil(self.mockFlowFactory.mockWebLoginFlow) + XCTAssertEqual(process.loginRoute, .webLogin) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } + #endif + + // MARK: - Edge Cases Tests + + func testProcessStop() { + let expect = expectation(description: "process stop") + + let process = createLoginProcess() + + var receivedError: Error? + process.onFail.delegate(on: self) { (self, error) in + receivedError = error + expect.fulfill() + } + + process.stop() + + waitForExpectations(timeout: 1.0) + + XCTAssertNotNil(receivedError) + if let lineError = receivedError as? LineSDKError, + case .authorizeFailed(reason: .forceStopped) = lineError { + // Expected error type + } else { + XCTFail("Expected forceStopped error") + } + } + + func testFlowReset() { + let expect = expectation(description: "flow reset") + + let process = createLoginProcess() + mockFlowFactory.configureAllFlowsToSucceed() + + process.start() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + // Verify flow is set + XCTAssertNotNil(process.appUniversalLinkFlow) + + // Stop process (which should reset flows) + process.stop() + + // Verify flows are reset + XCTAssertNil(process.appUniversalLinkFlow) + XCTAssertNil(process.appAuthSchemeFlow) + XCTAssertNil(process.webLoginFlow) + XCTAssertNil(process.loginRoute) + XCTAssertNil(process.appSwitchingObserver) + + expect.fulfill() + } + + waitForExpectations(timeout: 1.0) + } +} diff --git a/LineSDK/LineSDKTests/Login/LoginProcessMocks.swift b/LineSDK/LineSDKTests/Login/LoginProcessMocks.swift new file mode 100644 index 00000000..16d0a88e --- /dev/null +++ b/LineSDK/LineSDKTests/Login/LoginProcessMocks.swift @@ -0,0 +1,180 @@ +// +// LoginProcessMocks.swift +// +// Copyright (c) 2016-present, LY Corporation. All rights reserved. +// +// You are hereby granted a non-exclusive, worldwide, royalty-free license to use, +// copy and distribute this software in source code or binary form for use +// in connection with the web services and APIs provided by LY Corporation. +// +// As with any software that integrates with the LY Corporation platform, your use of this software +// is subject to the LINE Developers Agreement [http://terms2.line.me/LINE_Developers_Agreement]. +// This copyright notice shall be included in all copies or substantial portions of the software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// + +import XCTest +import UIKit +import Foundation +@testable import LineSDK + +// MARK: - Mock ApplicationOpener + +/// Mock ApplicationOpener for testing +@MainActor +class MockApplicationOpener: ApplicationOpener { + + var shouldSucceed: Bool = true + var openCallCount = 0 + var lastOpenedURL: URL? + var lastOptions: [UIApplication.OpenExternalURLOptionsKey : Any]? + + func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey : Any], completionHandler completion: (@MainActor @Sendable (Bool) -> Void)?) { + openCallCount += 1 + lastOpenedURL = url + lastOptions = options + + // Simulate async callback + DispatchQueue.main.async { + completion?(self.shouldSucceed) + } + } +} + +// MARK: - Mock LINEAvailabilityChecker + +/// Mock LINEAvailabilityChecker for testing +struct MockLINEAvailabilityChecker: LINEAvailabilityChecker { + let isLINEInstalled: Bool + + init(isLINEInstalled: Bool = true) { + self.isLINEInstalled = isLINEInstalled + } +} + +// MARK: - Mock Flow Implementations + +/// Mock AppUniversalLinkFlow for testing +@MainActor +class MockAppUniversalLinkFlow: AppUniversalLinkFlowType { + let onNext = Delegate() + var shouldSucceed: Bool = true + var startCallCount = 0 + + func start() { + startCallCount += 1 + DispatchQueue.main.async { + self.onNext.call(self.shouldSucceed) + } + } +} + +/// Mock AppAuthSchemeFlow for testing +@MainActor +class MockAppAuthSchemeFlow: AppAuthSchemeFlowType { + let onNext = Delegate() + var shouldSucceed: Bool = true + var startCallCount = 0 + + func start() { + startCallCount += 1 + DispatchQueue.main.async { + self.onNext.call(self.shouldSucceed) + } + } +} + +/// Mock WebLoginFlow for testing +@MainActor +class MockWebLoginFlow: WebLoginFlowType { + let onNext = Delegate() + let onCancel = Delegate<(), Void>() + var nextResult: WebLoginFlow.Next = .safariViewController + var startCallCount = 0 + var dismissCallCount = 0 + + func start(in viewController: UIViewController?) { + startCallCount += 1 + DispatchQueue.main.async { + self.onNext.call(self.nextResult) + } + } + + func dismiss() { + dismissCallCount += 1 + } +} + +// MARK: - Mock LoginFlowFactory + +/// Mock LoginFlowFactory for testing +@MainActor +class MockLoginFlowFactory: LoginFlowFactory { + var mockUniversalLinkFlow: MockAppUniversalLinkFlow? + var mockAuthSchemeFlow: MockAppAuthSchemeFlow? + var mockWebLoginFlow: MockWebLoginFlow? + + // Configuration for created flows + var universalLinkShouldSucceed: Bool = true + var authSchemeShouldSucceed: Bool = true + var webLoginResult: WebLoginFlow.Next = .safariViewController + + func createAppUniversalLinkFlow(parameter: LoginProcess.FlowParameters) -> AppUniversalLinkFlowType { + let flow = MockAppUniversalLinkFlow() + flow.shouldSucceed = universalLinkShouldSucceed + mockUniversalLinkFlow = flow + return flow + } + + func createAppAuthSchemeFlow(parameter: LoginProcess.FlowParameters) -> AppAuthSchemeFlowType { + let flow = MockAppAuthSchemeFlow() + flow.shouldSucceed = authSchemeShouldSucceed + mockAuthSchemeFlow = flow + return flow + } + + func createWebLoginFlow(parameter: LoginProcess.FlowParameters) -> WebLoginFlowType { + let flow = MockWebLoginFlow() + flow.nextResult = webLoginResult + mockWebLoginFlow = flow + return flow + } +} + +// MARK: - Test Helper Extensions + +extension MockLoginFlowFactory { + /// Configure all flows to succeed + func configureAllFlowsToSucceed() { + universalLinkShouldSucceed = true + authSchemeShouldSucceed = true + webLoginResult = .safariViewController + } + + /// Configure Universal Link to fail, Auth Scheme to succeed + func configureUniversalLinkFailAuthSchemeSucceed() { + universalLinkShouldSucceed = false + authSchemeShouldSucceed = true + webLoginResult = .safariViewController + } + + /// Configure Universal Link and Auth Scheme to fail, Web Login to succeed + func configureAllAppFlowsFailWebSucceed() { + universalLinkShouldSucceed = false + authSchemeShouldSucceed = false + webLoginResult = .safariViewController + } + + /// Configure Universal all flows to fail + func configureAllFlowsToFail(with webError: Error = LineSDKError.authorizeFailed(reason: .malformedHierarchy)) { + universalLinkShouldSucceed = false + authSchemeShouldSucceed = false + webLoginResult = .error(webError) + } +} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 68e41744..23653c67 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -110,7 +110,7 @@ platform :ios do lane :lint_pod do swift_version = ENV["SWIFT_VERSION"] || "6.0" - Action.sh("bundle exec pod lib lint ../LineSDKSwift.podspec --swift-version=#{swift_version}") + Action.sh("bundle exec pod lib lint ../LineSDKSwift.podspec --swift-version=#{swift_version} --allow-warnings") end lane :lint_spm do