mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
refactor: extract InstanceFormViewModel (MVVM)
Move the login form's field state, validation, and the authenticate() action into an @MainActor InstanceFormViewModel: ObservableObject, injected with the AuthenticationService via its initializer (no global singleton). InstanceFormView becomes a thin view bound through @StateObject; SwiftData persistence and dismiss stay in the view. Adds InstanceFormViewModelTests covering the login success and failure paths with a fake service. MVVM is the interim architecture bridge until TCA lands. Generated with Claude Opus 4.8 (1M)
This commit is contained in:
parent
20ab61eb43
commit
48e64afb16
3 changed files with 290 additions and 175 deletions
123
Forji/Forji/ViewModels/InstanceFormViewModel.swift
Normal file
123
Forji/Forji/ViewModels/InstanceFormViewModel.swift
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import Combine
|
||||
import ForgejoKit
|
||||
import Foundation
|
||||
|
||||
/// View model for ``InstanceFormView``.
|
||||
///
|
||||
/// Owns the login form's field state, validation, and the authentication action,
|
||||
/// keeping the view thin. The auth service is injected so the model can be tested
|
||||
/// against a fake without reaching for a global singleton.
|
||||
@MainActor
|
||||
final class InstanceFormViewModel: ObservableObject {
|
||||
enum AuthMode: String, CaseIterable {
|
||||
case credentials = "Password"
|
||||
case token = "API Token"
|
||||
}
|
||||
|
||||
@Published var name: String
|
||||
@Published var serverURL: String
|
||||
@Published var username: String
|
||||
@Published var password: String
|
||||
@Published var apiToken: String
|
||||
@Published var otpCode = ""
|
||||
@Published var authMode: AuthMode
|
||||
@Published var allowSelfSignedCertificates: Bool
|
||||
@Published var isDefault: Bool
|
||||
@Published var needsOTP = false
|
||||
@Published var isLoading = false
|
||||
@Published var errorMessage: String?
|
||||
@Published var showError = false
|
||||
|
||||
let authService: AuthenticationService
|
||||
|
||||
init(
|
||||
authService: AuthenticationService,
|
||||
name: String = "",
|
||||
serverURL: String = "",
|
||||
username: String = "",
|
||||
password: String = "",
|
||||
apiToken: String = "",
|
||||
authMode: AuthMode = .credentials,
|
||||
allowSelfSignedCertificates: Bool = false,
|
||||
isDefault: Bool = false,
|
||||
) {
|
||||
self.authService = authService
|
||||
self.name = name
|
||||
self.serverURL = serverURL
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.apiToken = apiToken
|
||||
self.authMode = authMode
|
||||
self.allowSelfSignedCertificates = allowSelfSignedCertificates
|
||||
self.isDefault = isDefault
|
||||
}
|
||||
|
||||
/// Whether the submit button should be enabled for the given mode.
|
||||
func canSubmit(isAddMode: Bool) -> Bool {
|
||||
guard !isLoading, !serverURL.isEmpty else { return false }
|
||||
switch authMode {
|
||||
case .credentials:
|
||||
if username.isEmpty { return false }
|
||||
if isAddMode, password.isEmpty { return false }
|
||||
if needsOTP, otpCode.isEmpty { return false }
|
||||
return true
|
||||
case .token:
|
||||
return !(isAddMode && apiToken.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticates with the current field values. Returns `true` on success.
|
||||
///
|
||||
/// Manages `isLoading` and surfaces failures via `errorMessage`/`showError`.
|
||||
/// An OTP challenge flips `needsOTP` and returns `false` so the view can prompt.
|
||||
func authenticate() async -> Bool {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
try await performLogin()
|
||||
return true
|
||||
} catch AuthenticationError.otpRequired {
|
||||
needsOTP = true
|
||||
return false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a validation error without attempting authentication.
|
||||
func presentError(_ message: String) {
|
||||
errorMessage = message
|
||||
showError = true
|
||||
}
|
||||
|
||||
private func performLogin() async throws {
|
||||
switch authMode {
|
||||
case .token:
|
||||
try await authService.loginWithToken(
|
||||
serverURL: serverURL,
|
||||
token: apiToken,
|
||||
allowSelfSigned: allowSelfSignedCertificates,
|
||||
)
|
||||
case .credentials:
|
||||
if needsOTP, !otpCode.isEmpty {
|
||||
try await authService.loginWithOTP(
|
||||
serverURL: serverURL,
|
||||
username: username,
|
||||
password: password,
|
||||
otp: otpCode,
|
||||
allowSelfSigned: allowSelfSignedCertificates,
|
||||
)
|
||||
} else {
|
||||
try await authService.login(
|
||||
serverURL: serverURL,
|
||||
username: username,
|
||||
password: password,
|
||||
allowSelfSigned: allowSelfSignedCertificates,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@ import ForgejoKit
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
// swiftlint:disable:next type_body_length
|
||||
struct InstanceFormView: View {
|
||||
enum Mode: Identifiable {
|
||||
case add
|
||||
|
|
@ -16,15 +15,10 @@ struct InstanceFormView: View {
|
|||
}
|
||||
}
|
||||
|
||||
enum AuthMode: String, CaseIterable {
|
||||
case credentials = "Password"
|
||||
case token = "API Token"
|
||||
}
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Query(sort: \ForgejoInstance.lastUsed, order: .reverse) private var instances: [ForgejoInstance]
|
||||
@State private var authService: AuthenticationService
|
||||
@StateObject private var viewModel: InstanceFormViewModel
|
||||
|
||||
let mode: Mode
|
||||
|
||||
|
|
@ -36,34 +30,20 @@ struct InstanceFormView: View {
|
|||
@AppStorage("dev_allowSelfSigned") private var devAllowSelfSigned = false
|
||||
#endif
|
||||
|
||||
@State private var authMode: AuthMode = .credentials
|
||||
@State private var name = ""
|
||||
@State private var serverURL = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var apiToken = ""
|
||||
@State private var allowSelfSignedCertificates = false
|
||||
@State private var isDefault = false
|
||||
@State private var otpCode = ""
|
||||
@State private var needsOTP = false
|
||||
@State private var isLoading = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showError = false
|
||||
|
||||
init(authService: AuthenticationService, mode: Mode) {
|
||||
self.authService = authService
|
||||
self.mode = mode
|
||||
_viewModel = StateObject(wrappedValue: InstanceFormViewModel(authService: authService))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Forgejo Instance") {
|
||||
TextField("Name (optional)", text: $name)
|
||||
TextField("Name (optional)", text: $viewModel.name)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("instance-name-field")
|
||||
|
||||
TextField("Server URL", text: $serverURL)
|
||||
TextField("Server URL", text: $viewModel.serverURL)
|
||||
.textContentType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
|
|
@ -72,25 +52,25 @@ struct InstanceFormView: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
if authMode == .credentials {
|
||||
TextField("Username", text: $username)
|
||||
if viewModel.authMode == .credentials {
|
||||
TextField("Username", text: $viewModel.username)
|
||||
.textContentType(.username)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("login-username-field")
|
||||
|
||||
SecureField("Password", text: $password)
|
||||
SecureField("Password", text: $viewModel.password)
|
||||
.textContentType(.password)
|
||||
.accessibilityIdentifier("login-password-field")
|
||||
|
||||
if needsOTP {
|
||||
TextField("One-Time Code", text: $otpCode)
|
||||
if viewModel.needsOTP {
|
||||
TextField("One-Time Code", text: $viewModel.otpCode)
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.accessibilityIdentifier("login-otp-field")
|
||||
}
|
||||
} else {
|
||||
SecureField("API Token", text: $apiToken)
|
||||
SecureField("API Token", text: $viewModel.apiToken)
|
||||
.textContentType(.password)
|
||||
.autocorrectionDisabled()
|
||||
.accessibilityIdentifier("login-token-field")
|
||||
|
|
@ -98,9 +78,9 @@ struct InstanceFormView: View {
|
|||
} header: {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Credentials")
|
||||
Picker("Login Method", selection: $authMode) {
|
||||
ForEach(AuthMode.allCases, id: \.self) { mode in
|
||||
Text(mode.rawValue).tag(mode)
|
||||
Picker("Login Method", selection: $viewModel.authMode) {
|
||||
ForEach(InstanceFormViewModel.AuthMode.allCases, id: \.self) { authOption in
|
||||
Text(authOption.rawValue).tag(authOption)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
|
@ -108,9 +88,9 @@ struct InstanceFormView: View {
|
|||
.textCase(nil)
|
||||
.padding(.bottom, 4)
|
||||
} footer: {
|
||||
if authMode == .token, case .edit = mode {
|
||||
if viewModel.authMode == .token, case .edit = mode {
|
||||
Text("Leave token blank to keep the existing one.")
|
||||
} else if authMode == .token {
|
||||
} else if viewModel.authMode == .token {
|
||||
Text(
|
||||
"""
|
||||
Generate a token under Settings → Applications with the following scopes:
|
||||
|
|
@ -121,7 +101,7 @@ struct InstanceFormView: View {
|
|||
• read:organization
|
||||
""",
|
||||
)
|
||||
} else if needsOTP {
|
||||
} else if viewModel.needsOTP {
|
||||
Text("Enter the 6-digit code from your authenticator app.")
|
||||
} else if case .edit = mode {
|
||||
Text("Leave password blank to keep the existing one.")
|
||||
|
|
@ -129,14 +109,14 @@ struct InstanceFormView: View {
|
|||
}
|
||||
|
||||
Section {
|
||||
Toggle("Accept Self-Signed Certificates", isOn: $allowSelfSignedCertificates)
|
||||
Toggle("Accept Self-Signed Certificates", isOn: $viewModel.allowSelfSignedCertificates)
|
||||
} footer: {
|
||||
Text("Enable this if your Forgejo instance uses a self-signed SSL certificate.")
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle("Default Instance", isOn: $isDefault)
|
||||
Toggle("Default Instance", isOn: $viewModel.isDefault)
|
||||
} footer: {
|
||||
Text("The default instance will be connected automatically on launch.")
|
||||
.font(.caption)
|
||||
|
|
@ -145,16 +125,10 @@ struct InstanceFormView: View {
|
|||
Section {
|
||||
LoadingActionButton(
|
||||
title: isAddMode ? "Login" : "Save",
|
||||
isLoading: isLoading,
|
||||
isLoading: viewModel.isLoading,
|
||||
action: handleSave,
|
||||
)
|
||||
.disabled(
|
||||
isLoading || serverURL.isEmpty
|
||||
|| (authMode == .credentials
|
||||
&& (username.isEmpty || (isAddMode && password.isEmpty)
|
||||
|| (needsOTP && otpCode.isEmpty)))
|
||||
|| (authMode == .token && isAddMode && apiToken.isEmpty),
|
||||
)
|
||||
.disabled(!viewModel.canSubmit(isAddMode: isAddMode))
|
||||
.listRowBackground(Color.clear)
|
||||
.accessibilityIdentifier("login-button")
|
||||
}
|
||||
|
|
@ -166,7 +140,7 @@ struct InstanceFormView: View {
|
|||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
.errorAlert("Login Failed", message: $errorMessage, isPresented: $showError)
|
||||
.errorAlert("Login Failed", message: $viewModel.errorMessage, isPresented: $viewModel.showError)
|
||||
.onAppear {
|
||||
populateFields()
|
||||
}
|
||||
|
|
@ -178,145 +152,105 @@ struct InstanceFormView: View {
|
|||
return false
|
||||
}
|
||||
|
||||
private func populateFields() {
|
||||
switch mode {
|
||||
case .add:
|
||||
#if DEBUG
|
||||
if !devServerURL.isEmpty { serverURL = devServerURL }
|
||||
if !devUsername.isEmpty { username = devUsername }
|
||||
if !devPassword.isEmpty { password = devPassword }
|
||||
if !devApiToken.isEmpty { apiToken = devApiToken }
|
||||
allowSelfSignedCertificates = devAllowSelfSigned
|
||||
#endif
|
||||
case let .edit(instance):
|
||||
name = instance.name
|
||||
serverURL = instance.serverURL
|
||||
username = instance.username
|
||||
allowSelfSignedCertificates = instance.allowSelfSignedCertificates
|
||||
isDefault = instance.isDefault
|
||||
authMode = instance.useTokenAuth ? .token : .credentials
|
||||
}
|
||||
}
|
||||
|
||||
private func performLogin() async throws {
|
||||
switch authMode {
|
||||
case .token:
|
||||
try await authService.loginWithToken(
|
||||
serverURL: serverURL,
|
||||
token: apiToken,
|
||||
allowSelfSigned: allowSelfSignedCertificates,
|
||||
)
|
||||
case .credentials:
|
||||
if needsOTP, !otpCode.isEmpty {
|
||||
try await authService.loginWithOTP(
|
||||
serverURL: serverURL,
|
||||
username: username,
|
||||
password: password,
|
||||
otp: otpCode,
|
||||
allowSelfSigned: allowSelfSignedCertificates,
|
||||
)
|
||||
} else {
|
||||
try await authService.login(
|
||||
serverURL: serverURL,
|
||||
username: username,
|
||||
password: password,
|
||||
allowSelfSigned: allowSelfSignedCertificates,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var editingInstanceId: String? {
|
||||
if case let .edit(instance) = mode { return instance.serverURL + instance.username }
|
||||
return nil
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_body_length cyclomatic_complexity
|
||||
private func populateFields() {
|
||||
switch mode {
|
||||
case .add:
|
||||
#if DEBUG
|
||||
if !devServerURL.isEmpty { viewModel.serverURL = devServerURL }
|
||||
if !devUsername.isEmpty { viewModel.username = devUsername }
|
||||
if !devPassword.isEmpty { viewModel.password = devPassword }
|
||||
if !devApiToken.isEmpty { viewModel.apiToken = devApiToken }
|
||||
viewModel.allowSelfSignedCertificates = devAllowSelfSigned
|
||||
#endif
|
||||
case let .edit(instance):
|
||||
viewModel.name = instance.name
|
||||
viewModel.serverURL = instance.serverURL
|
||||
viewModel.username = instance.username
|
||||
viewModel.allowSelfSignedCertificates = instance.allowSelfSignedCertificates
|
||||
viewModel.isDefault = instance.isDefault
|
||||
viewModel.authMode = instance.useTokenAuth ? .token : .credentials
|
||||
}
|
||||
}
|
||||
|
||||
private func handleSave() {
|
||||
if !name.isEmpty {
|
||||
let conflict = instances.first { inst in
|
||||
let instId = inst.serverURL + inst.username
|
||||
return inst.name == name && instId != editingInstanceId
|
||||
}
|
||||
if conflict != nil {
|
||||
errorMessage = "An instance named \"\(name)\" already exists. Please choose a different name."
|
||||
showError = true
|
||||
return
|
||||
}
|
||||
if let conflictMessage = nameConflictMessage() {
|
||||
viewModel.presentError(conflictMessage)
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
switch mode {
|
||||
case .add:
|
||||
try await performLogin()
|
||||
|
||||
if isDefault {
|
||||
for inst in instances where inst.isDefault {
|
||||
inst.isDefault = false
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
|
||||
let resolvedUsername =
|
||||
authMode == .token ? (authService.currentUser?.login ?? username) : username
|
||||
let instance = ForgejoInstance(
|
||||
serverURL: normalizedURL,
|
||||
username: resolvedUsername,
|
||||
name: name,
|
||||
isDefault: isDefault,
|
||||
allowSelfSignedCertificates: allowSelfSignedCertificates,
|
||||
useTokenAuth: authMode == .token,
|
||||
)
|
||||
modelContext.insert(instance)
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
assertionFailure("SwiftData save failed: \(error)")
|
||||
}
|
||||
authService.currentInstance = instance
|
||||
|
||||
case let .edit(instance):
|
||||
if authMode == .token, !apiToken.isEmpty {
|
||||
try await performLogin()
|
||||
instance.username = authService.currentUser?.login ?? username
|
||||
} else if authMode == .credentials, !password.isEmpty {
|
||||
try await performLogin()
|
||||
instance.username = username
|
||||
}
|
||||
|
||||
instance.name = name
|
||||
instance.serverURL = ForgejoClient.normalizeServerURL(serverURL)
|
||||
instance.allowSelfSignedCertificates = allowSelfSignedCertificates
|
||||
instance.useTokenAuth = authMode == .token
|
||||
|
||||
if isDefault, !instance.isDefault {
|
||||
for inst in instances where inst.isDefault {
|
||||
inst.isDefault = false
|
||||
}
|
||||
}
|
||||
instance.isDefault = isDefault
|
||||
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
assertionFailure("SwiftData save failed: \(error)")
|
||||
}
|
||||
authService.currentInstance = instance
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case .add:
|
||||
guard await viewModel.authenticate() else { return }
|
||||
if viewModel.isDefault { clearOtherDefaults() }
|
||||
let instance = ForgejoInstance(
|
||||
serverURL: ForgejoClient.normalizeServerURL(viewModel.serverURL),
|
||||
username: resolvedUsername,
|
||||
name: viewModel.name,
|
||||
isDefault: viewModel.isDefault,
|
||||
allowSelfSignedCertificates: viewModel.allowSelfSignedCertificates,
|
||||
useTokenAuth: viewModel.authMode == .token,
|
||||
)
|
||||
modelContext.insert(instance)
|
||||
saveContext()
|
||||
viewModel.authService.currentInstance = instance
|
||||
dismiss()
|
||||
} catch AuthenticationError.otpRequired {
|
||||
needsOTP = true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
case let .edit(instance):
|
||||
if editModeNeedsAuth {
|
||||
guard await viewModel.authenticate() else { return }
|
||||
instance.username = resolvedUsername
|
||||
}
|
||||
instance.name = viewModel.name
|
||||
instance.serverURL = ForgejoClient.normalizeServerURL(viewModel.serverURL)
|
||||
instance.allowSelfSignedCertificates = viewModel.allowSelfSignedCertificates
|
||||
instance.useTokenAuth = viewModel.authMode == .token
|
||||
if viewModel.isDefault, !instance.isDefault { clearOtherDefaults() }
|
||||
instance.isDefault = viewModel.isDefault
|
||||
saveContext()
|
||||
viewModel.authService.currentInstance = instance
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var editModeNeedsAuth: Bool {
|
||||
(viewModel.authMode == .token && !viewModel.apiToken.isEmpty)
|
||||
|| (viewModel.authMode == .credentials && !viewModel.password.isEmpty)
|
||||
}
|
||||
|
||||
private var resolvedUsername: String {
|
||||
viewModel.authMode == .token
|
||||
? (viewModel.authService.currentUser?.login ?? viewModel.username)
|
||||
: viewModel.username
|
||||
}
|
||||
|
||||
private func nameConflictMessage() -> String? {
|
||||
guard !viewModel.name.isEmpty else { return nil }
|
||||
let conflict = instances.first { inst in
|
||||
let instId = inst.serverURL + inst.username
|
||||
return inst.name == viewModel.name && instId != editingInstanceId
|
||||
}
|
||||
guard conflict != nil else { return nil }
|
||||
return "An instance named \"\(viewModel.name)\" already exists. Please choose a different name."
|
||||
}
|
||||
|
||||
private func clearOtherDefaults() {
|
||||
for inst in instances where inst.isDefault {
|
||||
inst.isDefault = false
|
||||
}
|
||||
}
|
||||
|
||||
private func saveContext() {
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
assertionFailure("SwiftData save failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
Forji/ForjiTests/InstanceFormViewModelTests.swift
Normal file
58
Forji/ForjiTests/InstanceFormViewModelTests.swift
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import ForgejoKit
|
||||
@testable import Forji
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
struct InstanceFormViewModelTests {
|
||||
/// Stand-in for the real service: overrides the auth entry points so the
|
||||
/// view model can be exercised without touching the network or keychain.
|
||||
final class FakeAuthService: AuthenticationService {
|
||||
var loginErrorToThrow: Error?
|
||||
private(set) var loginCallCount = 0
|
||||
|
||||
override func login(
|
||||
serverURL _: String,
|
||||
username _: String,
|
||||
password _: String,
|
||||
allowSelfSigned _: Bool = false,
|
||||
) async throws {
|
||||
loginCallCount += 1
|
||||
if let loginErrorToThrow { throw loginErrorToThrow }
|
||||
isAuthenticated = true
|
||||
}
|
||||
}
|
||||
|
||||
@Test func loginSuccessReportsSuccessAndClearsLoading() async {
|
||||
let fake = FakeAuthService()
|
||||
let viewModel = InstanceFormViewModel(authService: fake)
|
||||
viewModel.serverURL = "https://forgejo.example.com"
|
||||
viewModel.username = "alice"
|
||||
viewModel.password = "hunter2"
|
||||
|
||||
let succeeded = await viewModel.authenticate()
|
||||
|
||||
#expect(succeeded)
|
||||
#expect(fake.loginCallCount == 1)
|
||||
#expect(viewModel.isLoading == false)
|
||||
#expect(viewModel.errorMessage == nil)
|
||||
#expect(viewModel.showError == false)
|
||||
}
|
||||
|
||||
@Test func loginFailureSurfacesErrorAndStopsLoading() async {
|
||||
struct LoginBoom: LocalizedError { var errorDescription: String? { "boom" } }
|
||||
let fake = FakeAuthService()
|
||||
fake.loginErrorToThrow = LoginBoom()
|
||||
let viewModel = InstanceFormViewModel(authService: fake)
|
||||
viewModel.serverURL = "https://forgejo.example.com"
|
||||
viewModel.username = "alice"
|
||||
viewModel.password = "wrong"
|
||||
|
||||
let succeeded = await viewModel.authenticate()
|
||||
|
||||
#expect(succeeded == false)
|
||||
#expect(viewModel.isLoading == false)
|
||||
#expect(viewModel.showError)
|
||||
#expect(viewModel.errorMessage == "boom")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue