diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift index 2145b6733e7..2bd86e1605e 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift @@ -33,6 +33,24 @@ public protocol AuthProviderUI { var provider: AuthProviderSwift { get } } +/// Lets ``AuthService/triggerSignIn(for:)`` start sign-in for a provider whose flow doesn't +/// produce a credential, such as phone or a custom flow that presents its own screens. +/// +/// Adopt it on a custom provider so a custom method picker layout can start its sign-in through +/// ``AuthMethodPickerState/select``. Otherwise, start sign-in from the provider's own button. +/// +/// Example: +/// ```swift +/// extension MyProviderAuthUI: AuthProviderAction { +/// func triggerAction() async throws { +/// try await startMySignInFlow() +/// } +/// } +/// ``` +public protocol AuthProviderAction: AuthProviderUI { + @MainActor func triggerAction() async throws +} + public enum AuthenticationState { case unauthenticated case authenticating @@ -184,6 +202,40 @@ public final class AuthService { providers.append(providerWithButton) } + /// The providers registered with ``registerProvider(providerWithButton:)``, in registration + /// order. Email and email-link sign-in aren't included. + public var registeredProviders: [AuthProviderUI] { + providers + } + + /// Starts sign-in with `provider` without rendering its button. + /// + /// ``AuthProviderAction`` providers run ``AuthProviderAction/triggerAction()``, in which case + /// this returns `nil`. Other credential-based providers sign in through ``signIn(_:)``. + /// Any other provider throws ``AuthServiceError/providerAuthenticationFailed(_:)``. + public func triggerSignIn(for provider: AuthProviderUI) async throws -> SignInOutcome? { + if let actionProvider = provider as? AuthProviderAction { + try await actionProvider.triggerAction() + return nil + } + if let credentialProvider = provider.provider as? CredentialAuthProviderSwift { + return try await signIn(credentialProvider) + } + throw AuthServiceError.providerAuthenticationFailed( + "\(provider.displayName) doesn't support starting sign-in programmatically. " + + "Adopt AuthProviderAction on the provider, or start sign-in from its own button." + ) + } + + /// Runs the email-link sign-in action, the same as the stock email-link button. + func selectEmailLink() { + if let callback = emailLinkSignInCallback { + callback() + } else { + navigator.push(.emailLink) + } + } + public func renderButtons(spacing: CGFloat = 16) -> AnyView { AnyView( VStack(spacing: spacing) { @@ -193,11 +245,7 @@ public final class AuthService { style: .email, accessibilityId: "sign-in-with-email-link-button" ) { - if let callback = self.emailLinkSignInCallback { - callback() - } else { - self.navigator.push(.emailLink) - } + self.selectEmailLink() } } ForEach(providers, id: \.id) { provider in diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift index 526861fb523..32852e8c47a 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerContent.swift @@ -17,7 +17,7 @@ import FirebaseAuthUIComponents import SwiftUI /// An ``AuthPickerView`` with custom sheet content, returned by -/// ``AuthPickerView/pickerContent(_:)`` and ``AuthPickerView/pickerDestination(_:)``. +/// `AuthPickerView`'s `pickerContent` and ``AuthPickerView/pickerDestination(_:)`` modifiers. /// /// It owns the authentication sheet, its `NavigationStack`, error alerts, and MFA and /// account-conflict handling; only the first screen and the pushed screens come from the slots. @@ -29,17 +29,22 @@ public struct AuthPickerContent< DestinationContent: View >: View { init(content: @escaping () -> Content, - pickerContent: @escaping () -> PickerContent, - destination: @escaping (AuthView) -> DestinationContent) { + pickerContent: @escaping (AuthMethodPickerState) -> PickerContent, + destination: @escaping (AuthView) -> DestinationContent, + showsPickerTitle: Bool = true) { self.content = content self.pickerContent = pickerContent self.destination = destination + self.showsPickerTitle = showsPickerTitle } @Environment(AuthService.self) private var authService private let content: () -> Content - private let pickerContent: () -> PickerContent + private let pickerContent: (AuthMethodPickerState) -> PickerContent private let destination: (AuthView) -> DestinationContent + /// `false` when the whole first screen is custom, so the library's large title doesn't sit + /// above the app's own heading. The close button stays so the sheet can still be dismissed. + private let showsPickerTitle: Bool // View-layer error state @State private var error: AlertError? @@ -51,14 +56,19 @@ public struct AuthPickerContent< @Bindable var navigator = authService.navigator NavigationStack(path: $navigator.routes) { root - .navigationTitle(authService.authenticationState == .unauthenticated ? authService - .string.authPickerTitle : "") - .navigationBarTitleDisplayMode(.large) + .navigationTitle( + showsPickerTitle && authService.authenticationState == .unauthenticated + ? authService.string.authPickerTitle : "" + ) + .navigationBarTitleDisplayMode(showsPickerTitle ? .large : .inline) .toolbar { toolbar } .navigationDestination(for: AuthView.self) { view in destination(view) + // Pushed screens inherit an `.automatic` title mode from the root. Keep them + // large when the root is inline because a custom first screen hid its title. + .navigationBarTitleDisplayMode(showsPickerTitle ? .automatic : .large) } } .environment(\.reportError, reportError) @@ -87,7 +97,9 @@ public struct AuthPickerContent< if authService.authenticationState == .authenticated { SignedInView() } else { - pickerContent() + AuthMethodPickerStateReader { state in + pickerContent(state) + } } } .overlay { @@ -107,14 +119,28 @@ public struct AuthPickerContent< } /// Replaces the signed-out first screen of the authentication sheet. See - /// ``AuthPickerView/pickerContent(_:)``. + /// the no-argument `pickerContent` on ``AuthPickerView``. public func pickerContent( @ViewBuilder _ pickerContent: @escaping () -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: { _ in pickerContent() }, + destination: destination, + showsPickerTitle: true + ) + } + + /// Replaces the whole signed-out first screen with your own, receiving an + /// ``AuthMethodPickerState``. See the matching `pickerContent` on ``AuthPickerView``. + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent ) -> AuthPickerContent { AuthPickerContent( content: content, pickerContent: pickerContent, - destination: destination + destination: destination, + showsPickerTitle: false ) } @@ -126,7 +152,8 @@ public struct AuthPickerContent< AuthPickerContent( content: content, pickerContent: pickerContent, - destination: destination + destination: destination, + showsPickerTitle: showsPickerTitle ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift index 17b142d3cd1..db685ca1fc0 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/AuthPickerView.swift @@ -20,7 +20,7 @@ import SwiftUI /// Wraps your app content and presents the authentication sheet whenever /// `AuthService.isPresented` is `true`. /// -/// Use ``pickerContent(_:)`` and ``pickerDestination(_:)`` to replace the sheet's first screen or +/// Use the `pickerContent` modifiers and ``pickerDestination(_:)`` to replace the sheet's first screen or /// any pushed screen while the library keeps driving navigation, MFA and account conflicts. /// /// Example: @@ -41,7 +41,7 @@ public struct AuthPickerView: View { public var body: some View { AuthPickerContent( content: content, - pickerContent: { DefaultAuthPickerContent() }, + pickerContent: { _ in DefaultAuthPickerContent() }, destination: { DefaultAuthPickerDestination(screen: $0) } ) } @@ -61,11 +61,38 @@ public struct AuthPickerView: View { ) -> AuthPickerContent { AuthPickerContent( content: content, - pickerContent: pickerContent, + pickerContent: { _ in pickerContent() }, destination: { DefaultAuthPickerDestination(screen: $0) } ) } + /// Replaces the whole signed-out first screen with your own, receiving an + /// ``AuthMethodPickerState`` to sign in with the registered providers. Call its `select` so MFA, + /// account conflicts and errors are handled the same as the default buttons. + /// + /// Your screen replaces the logo, email sign-in, terms and the large "Sign in" title (the close + /// button stays), so show your own heading, email entry (for example one built around + /// ``EmailAuthView``) and any legal text you need. To keep the stock screen and only lay out + /// the provider buttons, use ``DefaultAuthPickerContent/init(authMethodPicker:)``. + /// + /// Example: + /// ```swift + /// AuthPickerView { authenticatedApp } + /// .pickerContent { state in + /// MyLandingScreen(state: state) + /// } + /// ``` + public func pickerContent( + @ViewBuilder _ pickerContent: @escaping (AuthMethodPickerState) -> NewPickerContent + ) -> AuthPickerContent { + AuthPickerContent( + content: content, + pickerContent: pickerContent, + destination: { DefaultAuthPickerDestination(screen: $0) }, + showsPickerTitle: false + ) + } + /// Replaces the screens pushed inside the authentication sheet. /// /// Always end a `switch` over the screen with a `default` branch that returns @@ -92,7 +119,7 @@ public struct AuthPickerView: View { > { AuthPickerContent( content: content, - pickerContent: { DefaultAuthPickerContent() }, + pickerContent: { _ in DefaultAuthPickerContent() }, destination: destination ) } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift index c6e95be5414..cb444ca8582 100644 --- a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Views/DefaultAuthPickerContent.swift @@ -18,41 +18,160 @@ import SwiftUI /// The default signed-out first screen of the authentication sheet: the logo, email sign-in, /// the provider buttons and the terms. /// -/// Return it from ``AuthPickerView/pickerContent(_:)`` to keep the stock screen while adding -/// modifiers such as a background. +/// Return it from `AuthPickerView`'s no-argument `pickerContent` to keep the stock screen while adding +/// modifiers such as a background, or use ``init(authMethodPicker:)`` to lay out the provider +/// buttons yourself. @MainActor public struct DefaultAuthPickerContent: View { public init() where AuthMethodPicker == DefaultAuthMethodPicker { - authMethodPicker = { DefaultAuthMethodPicker() } + authMethodPicker = { _ in DefaultAuthMethodPicker() } + } + + /// Creates the default screen with your own layout for the provider buttons. + /// + /// The closure receives an ``AuthMethodPickerState``. Call its `select` to sign in with a + /// provider, so MFA, account conflicts and errors are handled the same as the default buttons. + /// Show an email-link button when `isEmailLinkEnabled` is `true`. + /// + /// Example: + /// ```swift + /// DefaultAuthPickerContent { state in + /// ForEach(state.providers, id: \.id) { provider in + /// Button(provider.displayName) { state.select(provider) } + /// } + /// if state.isEmailLinkEnabled { + /// Button("Email link", action: state.selectEmailLink) + /// } + /// } + /// ``` + public init(@ViewBuilder authMethodPicker: @escaping (AuthMethodPickerState) + -> AuthMethodPicker) { + self.authMethodPicker = authMethodPicker } @Environment(AuthService.self) private var authService - private let authMethodPicker: () -> AuthMethodPicker + private let authMethodPicker: (AuthMethodPickerState) -> AuthMethodPicker public var body: some View { - GeometryReader { proxy in - ScrollView { - VStack(spacing: 24) { - Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 100, height: 100) - if authService.emailPasswordSignInEnabled { - EmailAuthView() + ScrollView { + VStack(spacing: 24) { + Image(authService.configuration.logo ?? Assets.firebaseAuthLogo) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 100, height: 100) + if authService.emailPasswordSignInEnabled { + EmailAuthView() + } + Divider() + VStack { + AuthMethodPickerStateReader { state in + authMethodPicker(state) } - Divider() - authMethodPicker() - .padding(.horizontal, proxy.size.width * 0.14) - PrivacyTOCsView(displayMode: .full) } + PrivacyTOCsView(displayMode: .full) } } .safeAreaPadding() } } +/// Builds an ``AuthMethodPickerState`` from the environment and passes it to its content, so +/// provider selection routes MFA, account conflicts and errors through the same handlers as the +/// default buttons. Used by ``DefaultAuthPickerContent`` and ``AuthPickerContent``. +@MainActor +struct AuthMethodPickerStateReader: View { + init(@ViewBuilder content: @escaping (AuthMethodPickerState) -> Content) { + self.content = content + } + + @Environment(AuthService.self) private var authService + @Environment(\.mfaHandler) private var mfaHandler + @Environment(\.accountConflictHandler) private var accountConflictHandler + @Environment(\.reportError) private var reportError + private let content: (AuthMethodPickerState) -> Content + + var body: some View { + content(AuthMethodPickerState( + providers: authService.registeredProviders, + select: selectProvider, + isEmailLinkEnabled: authService.emailLinkSignInEnabled, + selectEmailLink: authService.selectEmailLink + )) + } + + private func selectProvider(_ provider: AuthProviderUI) { + Task { + let result: Result + do { + let outcome = try await authService.triggerSignIn(for: provider) + result = .success(outcome) + } catch { + result = .failure(error) + } + AuthMethodPickerState.route( + result, + mfaHandler: mfaHandler, + accountConflictHandler: accountConflictHandler, + reportError: reportError + ) + } + } +} + +/// The providers and sign-in actions passed to a custom first screen in +/// `AuthPickerView.pickerContent { state in ... }`, or to a custom provider layout in +/// ``DefaultAuthPickerContent/init(authMethodPicker:)``. +public struct AuthMethodPickerState { + /// The registered providers, in registration order. Email-link sign-in isn't included; see + /// ``isEmailLinkEnabled``. + public let providers: [AuthProviderUI] + + /// Signs in with a provider from ``providers``. A provider that is neither credential-based + /// nor an ``AuthProviderAction`` reports an error instead. + public let select: @MainActor (AuthProviderUI) -> Void + + /// Whether email-link sign-in is enabled on the ``AuthService``. + public let isEmailLinkEnabled: Bool + + /// Starts email-link sign-in, the same as the stock email-link button. + public let selectEmailLink: @MainActor () -> Void + + /// Creates a state, for example to preview a custom layout with stub actions. + public init(providers: [AuthProviderUI], + select: @escaping @MainActor (AuthProviderUI) -> Void, + isEmailLinkEnabled: Bool, + selectEmailLink: @escaping @MainActor () -> Void) { + self.providers = providers + self.select = select + self.isEmailLinkEnabled = isEmailLinkEnabled + self.selectEmailLink = selectEmailLink + } + + /// Hands a provider sign-in result to the same handlers the default provider buttons use: + /// MFA and account conflicts go to their handlers, and other errors are reported. + @MainActor + static func route(_ result: Result, + mfaHandler: ((MFARequired) -> Void)?, + accountConflictHandler: ((AccountConflictContext) -> Void)?, + reportError: ((Error) -> Void)?) { + switch result { + case let .success(.mfaRequired(mfaInfo)): + mfaHandler?(mfaInfo) + case .success: + break + case let .failure(AuthServiceError.accountConflict(ctx)) where accountConflictHandler != nil: + accountConflictHandler?(ctx) + case let .failure(error): + reportError?(error) + } + } +} + /// The default provider list on ``DefaultAuthPickerContent``: every registered provider's -/// button, stacked vertically. +/// button, stacked vertically and inset from the screen edges. +/// +/// Its width is 72% of the nearest container, such as the enclosing `ScrollView`, not of its +/// direct parent. @MainActor public struct DefaultAuthMethodPicker: View { public init() {} @@ -60,8 +179,8 @@ public struct DefaultAuthMethodPicker: View { @Environment(AuthService.self) private var authService public var body: some View { - VStack { - authService.renderButtons() - } + authService.renderButtons() + // 72% of the container width, matching a 14% inset on each side. + .containerRelativeFrame(.horizontal) { length, _ in length * 0.72 } } } diff --git a/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift new file mode 100644 index 00000000000..c88876b984c --- /dev/null +++ b/FirebaseSwiftUI/FirebaseAuthSwiftUI/Tests/FirebaseAuthSwiftUITests/AuthMethodPickerStateTests.swift @@ -0,0 +1,100 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +@testable import FirebaseAuthSwiftUI +import Foundation +import Testing + +/// Records which handler `AuthMethodPickerState.route` called. +@MainActor +private final class RouteRecorder { + var mfa: MFARequired? + var conflict: AccountConflictContext? + var error: Error? + + func route(_ result: Result, handlesConflicts: Bool = true) { + AuthMethodPickerState.route( + result, + mfaHandler: { self.mfa = $0 }, + accountConflictHandler: handlesConflicts ? { self.conflict = $0 } : nil, + reportError: { self.error = $0 } + ) + } +} + +private struct TestError: Error {} + +private func conflictContext() -> AccountConflictContext { + AccountConflictContext( + conflictType: .accountExistsWithDifferentCredential, + credential: EmailAuthProvider.credential(withEmail: "test@example.com", password: "123456"), + underlyingError: TestError(), + message: "conflict", + email: "test@example.com" + ) +} + +@Suite("AuthMethodPickerState routing") +@MainActor +struct AuthMethodPickerStateRoutingTests { + @Test("MFA required goes to the MFA handler") + func mfaRequired() { + let recorder = RouteRecorder() + let mfa = MFARequired(hints: [.totp(displayName: nil, uid: "uid")]) + recorder.route(.success(.mfaRequired(mfa))) + #expect(recorder.mfa == mfa) + #expect(recorder.conflict == nil) + #expect(recorder.error == nil) + } + + @Test("A completed sign-in calls no handler") + func signedIn() { + let recorder = RouteRecorder() + recorder.route(.success(.signedIn(nil))) + recorder.route(.success(nil)) + #expect(recorder.mfa == nil) + #expect(recorder.conflict == nil) + #expect(recorder.error == nil) + } + + @Test("An account conflict goes to the conflict handler") + func accountConflict() { + let recorder = RouteRecorder() + let context = conflictContext() + recorder.route(.failure(AuthServiceError.accountConflict(context))) + #expect(recorder.conflict == context) + #expect(recorder.error == nil) + } + + @Test("An account conflict is reported when there is no conflict handler") + func accountConflictWithoutHandler() { + let recorder = RouteRecorder() + recorder.route( + .failure(AuthServiceError.accountConflict(conflictContext())), + handlesConflicts: false + ) + #expect(recorder.conflict == nil) + #expect(recorder.error != nil) + } + + @Test("Other errors are reported") + func otherError() { + let recorder = RouteRecorder() + recorder.route(.failure(TestError())) + #expect(recorder.error is TestError) + #expect(recorder.mfa == nil) + #expect(recorder.conflict == nil) + } +} diff --git a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift index 5d835b959d0..a816970098b 100644 --- a/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift +++ b/FirebaseSwiftUI/FirebasePhoneAuthSwiftUI/Sources/Services/PhoneAuthProviderAuthUI.swift @@ -41,3 +41,9 @@ public class PhoneAuthProviderAuthUI: AuthProviderUI { return AnyView(PhoneAuthButtonView(onTap: mainActorClosure)) } } + +extension PhoneAuthProviderAuthUI: AuthProviderAction { + @MainActor public func triggerAction() async throws { + onTap() + } +} diff --git a/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift new file mode 100644 index 00000000000..00ae7d88c4b --- /dev/null +++ b/e2eTest/FirebaseSwiftUIExample/FirebaseSwiftUIExampleTests/TriggerSignInTests.swift @@ -0,0 +1,225 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import FirebaseAuth +@testable import FirebaseAuthSwiftUI +import FirebaseCore +import FirebasePhoneAuthSwiftUI +import SwiftUI +import Testing + +private struct TestCredentialError: Error {} + +private final class FailingCredentialProvider: CredentialAuthProviderSwift { + func createAuthCredential() async throws -> AuthCredential { + throw TestCredentialError() + } +} + +private final class PlainProvider: AuthProviderSwift {} + +private class TestProviderUI: AuthProviderUI { + let id: String + let displayName: String + let provider: AuthProviderSwift + + init(id: String, provider: AuthProviderSwift) { + self.id = id + displayName = id + self.provider = provider + } + + @MainActor func authButton() -> AnyView { + AnyView(EmptyView()) + } +} + +private class TestActionProviderUI: TestProviderUI, AuthProviderAction { + var actionCount = 0 + + @MainActor func triggerAction() async throws { + actionCount += 1 + } +} + +@MainActor +struct TriggerSignInTests { + private func makeService() -> AuthService { + configureFirebaseIfNeeded() + return AuthService() + } + + @Test + func registeredProvidersKeepRegistrationOrder() { + let service = makeService() + service.registerProvider(providerWithButton: TestProviderUI(id: "a", provider: PlainProvider())) + service.registerProvider(providerWithButton: TestProviderUI(id: "b", provider: PlainProvider())) + + #expect(service.registeredProviders.map(\.id) == ["a", "b"]) + } + + @Test + func credentialProviderSignsInThroughItsCredential() async { + let service = makeService() + let provider = TestProviderUI(id: "credential", provider: FailingCredentialProvider()) + + await #expect(throws: TestCredentialError.self) { + try await service.triggerSignIn(for: provider) + } + } + + @Test + func actionTakesPrecedenceOverCredential() async throws { + let service = makeService() + let provider = TestActionProviderUI(id: "both", provider: FailingCredentialProvider()) + + let outcome = try await service.triggerSignIn(for: provider) + + #expect(outcome == nil) + #expect(provider.actionCount == 1) + } + + @Test + func actionProviderRunsItsAction() async throws { + let service = makeService() + let provider = TestActionProviderUI(id: "action", provider: PlainProvider()) + + let outcome = try await service.triggerSignIn(for: provider) + + #expect(outcome == nil) + #expect(provider.actionCount == 1) + } + + @Test + func unsupportedProviderThrows() async { + let service = makeService() + let provider = TestProviderUI(id: "custom", provider: PlainProvider()) + + await #expect { + try await service.triggerSignIn(for: provider) + } throws: { error in + guard case AuthServiceError.providerAuthenticationFailed = error else { return false } + return true + } + } + + @Test + func phoneProviderRunsItsTapAction() async throws { + let service = makeService() + var tapCount = 0 + let provider = PhoneAuthProviderAuthUI { tapCount += 1 } + + let outcome = try await service.triggerSignIn(for: provider) + + #expect(outcome == nil) + #expect(tapCount == 1) + } + + @Test + func selectEmailLinkRunsCustomCallback() { + let service = makeService() + var callbackCount = 0 + _ = service.withEmailLinkSignIn { callbackCount += 1 } + + service.selectEmailLink() + + #expect(callbackCount == 1) + #expect(service.authView == nil) + } + + @Test + func selectEmailLinkPushesEmailLinkScreenByDefault() { + let service = makeService() + _ = service.withEmailLinkSignIn() + + service.selectEmailLink() + + #expect(service.authView == .emailLink) + } +} + +@MainActor +struct AuthMethodPickerStateRouteTests { + private final class Calls { + var mfa: [MFARequired] = [] + var conflicts: [AccountConflictContext] = [] + var errors: [Error] = [] + } + + private func route(_ result: Result, + withConflictHandler: Bool = true) -> Calls { + let calls = Calls() + AuthMethodPickerState.route( + result, + mfaHandler: { calls.mfa.append($0) }, + accountConflictHandler: withConflictHandler ? { calls.conflicts.append($0) } : nil, + reportError: { calls.errors.append($0) } + ) + return calls + } + + private func conflictError() -> AuthServiceError { + .accountConflict(AccountConflictContext( + conflictType: .accountExistsWithDifferentCredential, + credential: EmailAuthProvider.credential(withEmail: "a@b.com", password: "123456"), + underlyingError: TestCredentialError(), + message: "conflict", + email: "a@b.com" + )) + } + + @Test + func mfaRequiredGoesToMFAHandler() { + let calls = route(.success(.mfaRequired(MFARequired(hints: [])))) + + #expect(calls.mfa.count == 1) + #expect(calls.conflicts.isEmpty) + #expect(calls.errors.isEmpty) + } + + @Test + func successAndNoOutcomeCallNothing() { + for result: Result in [.success(nil), .success(.signedIn(nil))] { + let calls = route(result) + + #expect(calls.mfa.isEmpty) + #expect(calls.conflicts.isEmpty) + #expect(calls.errors.isEmpty) + } + } + + @Test + func accountConflictGoesToConflictHandler() { + let calls = route(.failure(conflictError())) + + #expect(calls.conflicts.count == 1) + #expect(calls.errors.isEmpty) + } + + @Test + func accountConflictWithoutHandlerIsReported() { + let calls = route(.failure(conflictError()), withConflictHandler: false) + + #expect(calls.errors.count == 1) + } + + @Test + func otherErrorsAreReported() { + let calls = route(.failure(TestCredentialError())) + + #expect(calls.errors.first is TestCredentialError) + #expect(calls.mfa.isEmpty) + #expect(calls.conflicts.isEmpty) + } +}