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:
Claude 2026-05-29 15:13:05 -04:00
parent 20ab61eb43
commit 48e64afb16
3 changed files with 290 additions and 175 deletions

View 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,
)
}
}
}
}

View file

@ -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)")
}
}
}

View 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")
}
}