v0.1.0 — initialer Sprint, vollständige Auth-Reise als SwiftUI

Phase 2 aus dem Native-Auth-Vollausbau-Plan (Option A, siehe
../mana/docs/MANA_SWIFT.md). Entstanden weil drei Apps fast-
byte-identische LoginView.swift hatten und Sign-Up/Forgot-PW
komplett fehlten.

ManaAuthUI-Library mit:
- ManaBrandConfig — App-injizierte Theme-Werte (forest für Cards/
  Manaspur, mana-default für Memoro), Environment-Key, View-Modifier
- Base-Components: ManaAuthScaffold, ManaPrimaryButton, ManaTextField,
  ManaSecureField + .manaEmailField()-Modifier
- ManaLoginView + LoginViewModel — Email/PW-Login, schaltet bei
  AuthError.emailNotVerified automatisch auf ManaEmailVerifyGateView
- ManaSignUpView + SignUpViewModel — Email/Name/PW + awaiting-
  Verification-Hinweis-Screen
- ManaEmailVerifyGateView + ViewModel — Resend-Verification
- ManaForgotPasswordView + ViewModel — Reset-Mail anfordern (immer
  generischer Hinweis, User-Enumeration-Schutz)
- ManaResetPasswordView + ViewModel — neues PW mit Token aus
  Universal-Link
- ManaChangeEmailView, ManaChangePasswordView, ManaDeleteAccountView
  + internal ViewModels — Account-Bausteine
- ManaDeleteAccountView ist zweistufig (Bestätigungs-Wort tippen
  + Passwort) → App-Store-Guideline 5.1.1(v) Pflicht-Surface

26/26 ViewModel-Tests grün via per-test-ID URLProtocol-Routing
(löst Parallel-Pollution zwischen .serialized Suites).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-13 19:22:42 +02:00
commit 0a2cb349b4
29 changed files with 2614 additions and 0 deletions

View file

@ -0,0 +1,104 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("Account-ViewModels (ChangeEmail/ChangePW/Delete)")
@MainActor
struct AccountViewModelTests {
/// Simuliert eingeloggten User über den echten `signIn`-Pfad
/// mit Mock-URL, weil `persistSession` internal in ManaCore ist.
private func signedInAuth() async -> MockedAuth {
let mocked = makeMockedAuth()
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
mocked.setHandler { _ in
(200, Data(#"{"accessToken":"\#(access)","refreshToken":"r"}"#.utf8))
}
await mocked.auth.signIn(email: "u@x.de", password: "pw")
return mocked
}
// MARK: - ChangeEmail
@Test("changeEmail erfolgreich → .done")
func changeEmailSuccess() async {
let mocked = await signedInAuth()
let model = ChangeEmailViewModel(auth: mocked.auth, callbackUniversalLink: nil)
model.newEmail = "neu@x.de"
mocked.setHandler { _ in (200, Data(#"{"success":true}"#.utf8)) }
await model.submit()
#expect(model.status == .done)
}
@Test("changeEmail ohne Session → .error mit notSignedIn")
func changeEmailNoSession() async {
let model = ChangeEmailViewModel(auth: makeMockedAuth().auth, callbackUniversalLink: nil)
model.newEmail = "neu@x.de"
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Nicht angemeldet")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
// MARK: - ChangePassword
@Test("changePassword erfolgreich → .done und cleared Felder")
func changePasswordSuccess() async {
let mocked = await signedInAuth()
let model = ChangePasswordViewModel(auth: mocked.auth)
model.currentPassword = "alt-lang-genug"
model.newPassword = "neu-lang-genug"
model.confirmPassword = "neu-lang-genug"
mocked.setHandler { _ in (200, Data(#"{"success":true}"#.utf8)) }
await model.submit()
#expect(model.status == .done)
#expect(model.currentPassword == "")
#expect(model.newPassword == "")
#expect(model.confirmPassword == "")
}
@Test("changePassword Mismatch → canSubmit false")
func changePasswordMismatch() {
let model = ChangePasswordViewModel(auth: makeMockedAuth().auth)
model.currentPassword = "alt"
model.newPassword = "neu-lang-genug"
model.confirmPassword = "anders-lang-genug"
#expect(model.canSubmit == false)
}
// MARK: - DeleteAccount
@Test("deleteAccount mit korrektem Bestätigungswort → .done")
func deleteAccountSuccess() async {
let mocked = await signedInAuth()
let model = DeleteAccountViewModel(auth: mocked.auth)
model.confirmationText = "LÖSCHEN"
model.password = "pw"
mocked.setHandler { request in
#expect(request.httpMethod == "DELETE")
return (200, Data(#"{"success":true}"#.utf8))
}
await model.submit()
#expect(model.status == .done)
#expect(mocked.auth.status == .signedOut)
}
@Test("deleteAccount mit falschem Bestätigungswort → canSubmit false")
func deleteAccountWrongConfirmation() {
let model = DeleteAccountViewModel(auth: makeMockedAuth().auth)
model.confirmationText = "delete"
model.password = "pw"
#expect(model.canSubmit == false)
model.confirmationText = "LÖSCHEN"
#expect(model.canSubmit == true)
// Case-insensitive
model.confirmationText = "löschen"
#expect(model.canSubmit == true)
}
}

View file

@ -0,0 +1,44 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("EmailVerifyGateViewModel")
@MainActor
struct EmailVerifyGateViewModelTests {
@Test("resend erfolgreich → .resent")
func resendSuccess() async {
let mocked = makeMockedAuth()
let model = EmailVerifyGateViewModel(email: "u@x.de", auth: mocked.auth)
mocked.setHandler { _ in (200, Data(#"{"success":true}"#.utf8)) }
await model.resend()
if case .resent = model.status {
#expect(Bool(true))
} else {
Issue.record("Expected .resent, got \(model.status)")
}
}
@Test("Rate-Limit → .error mit Retry-Hint")
func resendRateLimited() async {
let mocked = makeMockedAuth()
let model = EmailVerifyGateViewModel(email: "u@x.de", auth: mocked.auth)
mocked.setHandler { _ in
(
429,
Data(#"{"error":"RATE_LIMITED","retryAfterSec":42,"status":429}"#.utf8),
["Retry-After": "42"]
)
}
await model.resend()
if case let .error(message) = model.status {
#expect(message == "Zu viele Versuche. Bitte warte 42s.")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
}

View file

@ -0,0 +1,97 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("ForgotPasswordViewModel + ResetPasswordViewModel")
@MainActor
struct ForgotResetViewModelTests {
// MARK: - ForgotPassword
@Test("forgotPassword erfolgreich → .sent")
func forgotSuccess() async {
let mocked = makeMockedAuth()
let model = ForgotPasswordViewModel(
auth: mocked.auth,
resetUniversalLink: URL(string: "https://cardecky.mana.how/auth/reset")!
)
model.email = "u@x.de"
let captured = MockURLProtocol.Capture()
mocked.setHandler { request in
captured.store(request)
return (200, Data(#"{"success":true}"#.utf8))
}
await model.submit()
#expect(model.status == .sent)
#expect(captured.request?.url?.path == "/api/v1/auth/forgot-password")
}
@Test("forgotPassword leere Email → canSubmit false")
func forgotGuards() {
let model = ForgotPasswordViewModel(
auth: makeMockedAuth().auth,
resetUniversalLink: URL(string: "https://x.test/auth/reset")!
)
#expect(model.canSubmit == false)
model.email = "u@x.de"
#expect(model.canSubmit == true)
}
// MARK: - ResetPassword
@Test("resetPassword erfolgreich → .done")
func resetSuccess() async {
let mocked = makeMockedAuth()
let model = ResetPasswordViewModel(token: "tok123", auth: mocked.auth)
model.newPassword = "Neu-987654321"
model.confirmPassword = "Neu-987654321"
mocked.setHandler { _ in (200, Data(#"{"success":true}"#.utf8)) }
await model.submit()
#expect(model.status == .done)
#expect(model.newPassword == "")
#expect(model.confirmPassword == "")
}
@Test("resetPassword mit abgelaufenem Token → .error")
func resetTokenExpired() async {
let mocked = makeMockedAuth()
let model = ResetPasswordViewModel(token: "old", auth: mocked.auth)
model.newPassword = "Neu-987654321"
model.confirmPassword = "Neu-987654321"
mocked.setHandler { _ in
(400, Data(#"{"error":"TOKEN_EXPIRED","status":400}"#.utf8))
}
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Der Link ist abgelaufen. Bitte fordere einen neuen an.")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
@Test("resetPassword validationHint bei Mismatch")
func resetMismatch() {
let model = ResetPasswordViewModel(token: "t", auth: makeMockedAuth().auth)
model.newPassword = "lang-genug"
model.confirmPassword = "anders-lang"
#expect(model.validationHint == "Die Passwörter stimmen nicht überein.")
#expect(model.canSubmit == false)
}
@Test("resetPassword canSubmit erfordert ≥8 Zeichen und Match")
func resetCanSubmit() {
let model = ResetPasswordViewModel(token: "t", auth: makeMockedAuth().auth)
model.newPassword = "kurz"
model.confirmPassword = "kurz"
#expect(model.canSubmit == false)
model.newPassword = "lang-genug"
model.confirmPassword = "lang-genug"
#expect(model.canSubmit == true)
}
}

View file

@ -0,0 +1,86 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("LoginViewModel")
@MainActor
struct LoginViewModelTests {
@Test("Erfolgreicher Login setzt status auf .idle und cleared das Passwort")
func successfulLogin() async throws {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "Aa-123456789"
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
mocked.setHandler { _ in
(200, Data(#"{"accessToken":"\#(access)","refreshToken":"r"}"#.utf8))
}
await model.submit()
#expect(model.status == .idle)
#expect(model.password == "")
#expect(mocked.auth.status == .signedIn(email: "u@x.de"))
}
@Test("EMAIL_NOT_VERIFIED schaltet ViewModel auf .emailNotVerified")
func emailNotVerifiedPath() async {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "pw"
mocked.setHandler { _ in
(403, Data(#"{"error":"EMAIL_NOT_VERIFIED","status":403}"#.utf8))
}
await model.submit()
if case let .emailNotVerified(email) = model.status {
#expect(email == "u@x.de")
} else {
Issue.record("Expected .emailNotVerified, got \(model.status)")
}
}
@Test("Invalid-Credentials liefert .error mit deutscher Nachricht")
func invalidCredentials() async {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "wrong"
mocked.setHandler { _ in
(401, Data(#"{"error":"INVALID_CREDENTIALS","status":401}"#.utf8))
}
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Email oder Passwort falsch")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
@Test("canSubmit ist false bei leeren Feldern")
func canSubmitGuards() {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
#expect(model.canSubmit == false)
model.email = "u@x.de"
#expect(model.canSubmit == false)
model.password = "pw"
#expect(model.canSubmit == true)
}
@Test("resetToIdle bringt Status zurück auf idle")
func resetToIdleClearsState() {
let mocked = makeMockedAuth()
let model = LoginViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "pw"
model.resetToIdle()
#expect(model.status == .idle)
#expect(model.password == "")
}
}

View file

@ -0,0 +1,35 @@
import SwiftUI
import Testing
@testable import ManaAuthUI
@Suite("ManaBrandConfig")
struct ManaBrandConfigTests {
@Test("systemDefault setzt sinnvolle Defaults")
func systemDefaultDefaults() {
let config = ManaBrandConfig.systemDefault
#expect(config.appName == "mana")
#expect(config.tagline == nil)
#expect(config.logoSymbol == nil)
}
@Test("Apps können ein eigenes Brand-Config bauen")
func customConfig() {
let cardecky = ManaBrandConfig(
appName: "Cardecky",
tagline: "Karteikarten des Vereins mana e.V.",
logoSymbol: "rectangle.stack.fill",
background: .white,
foreground: .black,
surface: .gray,
mutedForeground: .gray,
border: .gray,
primary: .green,
primaryForeground: .white,
error: .red,
success: .green
)
#expect(cardecky.appName == "Cardecky")
#expect(cardecky.tagline == "Karteikarten des Vereins mana e.V.")
#expect(cardecky.logoSymbol == "rectangle.stack.fill")
}
}

View file

@ -0,0 +1,107 @@
import Foundation
import ManaCore
/// URLProtocol-Mock mit Pro-Test-Routing: jede Test-AuthClient-
/// Instanz kriegt eine eigene Test-ID als HTTP-Header, der Mock
/// routet nach ID zum richtigen Handler. Das löst die Parallel-
/// Pollution zwischen Test-Suites (mehrere `.serialized`-Suites
/// laufen untereinander parallel, der globale Handler-Slot wäre
/// sonst ein Race).
final class MockURLProtocol: URLProtocol, @unchecked Sendable {
typealias Handler = @Sendable (URLRequest) -> Any
nonisolated(unsafe) static var handlersStorage: [String: Handler] = [:]
static let handlersLock = NSLock()
static func register(testID: String, handler: @escaping Handler) {
handlersLock.lock(); defer { handlersLock.unlock() }
handlersStorage[testID] = handler
}
static func unregister(testID: String) {
handlersLock.lock(); defer { handlersLock.unlock() }
handlersStorage.removeValue(forKey: testID)
}
private static func lookup(testID: String) -> Handler? {
handlersLock.lock(); defer { handlersLock.unlock() }
return handlersStorage[testID]
}
final class Capture: @unchecked Sendable {
private let lock = NSLock()
private var stored: URLRequest?
func store(_ r: URLRequest) {
lock.lock(); defer { lock.unlock() }
stored = r
}
var request: URLRequest? {
lock.lock(); defer { lock.unlock() }
return stored
}
}
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func stopLoading() {}
override func startLoading() {
let testID = request.value(forHTTPHeaderField: "X-Test-ID") ?? ""
guard let handler = MockURLProtocol.lookup(testID: testID) else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}
let result = handler(request)
let status: Int
let body: Data
let headers: [String: String]
if let tuple = result as? (Int, Data, [String: String]) {
status = tuple.0; body = tuple.1; headers = tuple.2
} else if let tuple = result as? (Int, Data) {
status = tuple.0; body = tuple.1; headers = [:]
} else {
client?.urlProtocol(self, didFailWithError: URLError(.unknown))
return
}
let response = HTTPURLResponse(
url: request.url!,
statusCode: status,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: body)
client?.urlProtocolDidFinishLoading(self)
}
}
/// Bündelt einen `AuthClient` mit seiner Test-ID. Tests setzen den
/// Handler über die Test-ID statt über einen globalen Slot.
@MainActor
struct MockedAuth {
let auth: AuthClient
let testID: String
func setHandler(_ handler: @escaping MockURLProtocol.Handler) {
MockURLProtocol.register(testID: testID, handler: handler)
}
}
@MainActor
func makeMockedAuth() -> MockedAuth {
let testID = UUID().uuidString
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [MockURLProtocol.self]
configuration.httpAdditionalHeaders = ["X-Test-ID": testID]
let session = URLSession(configuration: configuration)
let config = DefaultManaAppConfig(
authBaseURL: URL(string: "https://auth.test")!,
keychainService: "ev.mana.test.\(testID)",
keychainAccessGroup: nil
)
return MockedAuth(auth: AuthClient(config: config, session: session), testID: testID)
}

View file

@ -0,0 +1,96 @@
import Foundation
import ManaCore
import Testing
@testable import ManaAuthUI
@Suite("SignUpViewModel")
@MainActor
struct SignUpViewModelTests {
@Test("Registrierung ohne Tokens → awaitingVerification")
func awaitsVerification() async {
let mocked = makeMockedAuth()
let model = SignUpViewModel(auth: mocked.auth)
model.email = "new@x.de"
model.password = "Aa-123456789"
mocked.setHandler { _ in
(200, Data(#"{"user":{"id":"u1","email":"new@x.de"}}"#.utf8))
}
await model.submit()
if case let .awaitingVerification(email) = model.status {
#expect(email == "new@x.de")
} else {
Issue.record("Expected .awaitingVerification, got \(model.status)")
}
#expect(model.password == "")
}
@Test("Registrierung mit Tokens → signedIn")
func registersAndSignsIn() async {
let mocked = makeMockedAuth()
let model = SignUpViewModel(auth: mocked.auth)
model.email = "new@x.de"
model.password = "Aa-123456789"
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
mocked.setHandler { _ in
(200, Data(#"""
{"user":{"id":"u1","email":"new@x.de"},"accessToken":"\#(access)","refreshToken":"r"}
"""#.utf8))
}
await model.submit()
#expect(model.status == .signedIn)
}
@Test("Email-Konflikt liefert lokalisierte Fehlermeldung")
func emailConflict() async {
let mocked = makeMockedAuth()
let model = SignUpViewModel(auth: mocked.auth)
model.email = "old@x.de"
model.password = "Aa-123456789"
mocked.setHandler { _ in
(409, Data(#"{"error":"EMAIL_ALREADY_REGISTERED","status":409}"#.utf8))
}
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Diese Email ist bereits registriert.")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
@Test("passwordHint warnt bei zu kurzem Passwort")
func passwordHint() {
let model = SignUpViewModel(auth: makeMockedAuth().auth)
model.password = ""
#expect(model.passwordHint == nil)
model.password = "kurz"
#expect(model.passwordHint == "Passwort muss mindestens 8 Zeichen lang sein.")
model.password = "lang-genug"
#expect(model.passwordHint == nil)
}
@Test("submit mit zu kurzem Passwort macht keinen Server-Call")
func submitGuardsShortPassword() async {
let mocked = makeMockedAuth()
let model = SignUpViewModel(auth: mocked.auth)
model.email = "u@x.de"
model.password = "kurz"
mocked.setHandler { _ in
Issue.record("Server darf nicht aufgerufen werden")
return (500, Data())
}
await model.submit()
if case let .error(message) = model.status {
#expect(message == "Passwort muss mindestens 8 Zeichen lang sein.")
} else {
Issue.record("Expected .error, got \(model.status)")
}
}
}