v0.2.0 — ManaAuthGate für Action-Level-Login-Eskalation
Apps mit Guest-/Login-optional-Modus brauchen kein Full-Screen-
Login-Gate mehr. Aktionen, die einen Account brauchen, werden in
gate.require { ... } gewrappt — Login wird zur Inline-Eskalation,
nicht zum App-Block.
ManaAuthGate — @Observable-State-Maschine:
- require(reason:work:) sync und async Overloads
- Bei .signedIn: Aktion läuft sofort
- Bei .signedOut/.guest: Sign-In-Sheet öffnet, Aktion gemerkt;
nach erfolgreichem Sign-In läuft sie automatisch
- lastReason als optionaler Telemetrie-Hint
ManaAuthGateModifier / View.manaAuthGate(_:signIn:) — hängt das
Sign-In-Sheet an einen Root-View, beobachtet auth.status. Wechsel
auf .signedIn schließt das Sheet + resolved pending; manuelles
Dismiss verwirft.
Konvention für native Apps:
1. bootstrap() → bei .signedOut → enterGuestMode()
2. Root-View zeigt immer App-Inhalte, nie eine Vollbild-Login-Wall
3. Account-Aktionen via gate.require { ... }
Memoro hat das informell schon. Cards komplett umgestellt (siehe
cards-native commit). Manaspur folgt.
7 neue Tests: sofortiger Run bei .signedIn, Defer bei .signedOut/
.guest, resolvePending, cancelPending, lastReason-Tracking.
Setzt mana-swift-core ≥ 1.2.0 voraus.
🤖 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:
parent
0a2cb349b4
commit
6417b4cd33
4 changed files with 357 additions and 0 deletions
125
Tests/ManaAuthUITests/ManaAuthGateTests.swift
Normal file
125
Tests/ManaAuthUITests/ManaAuthGateTests.swift
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import Foundation
|
||||
import ManaCore
|
||||
import Testing
|
||||
@testable import ManaAuthUI
|
||||
|
||||
@Suite("ManaAuthGate")
|
||||
@MainActor
|
||||
struct ManaAuthGateTests {
|
||||
@Test("require mit .signedIn führt Action sofort aus, kein Sheet")
|
||||
func runsImmediatelyWhenSignedIn() async throws {
|
||||
let mocked = makeMockedAuth()
|
||||
await signInMockedAuth(mocked)
|
||||
let gate = ManaAuthGate(auth: mocked.auth)
|
||||
|
||||
var didRun = false
|
||||
gate.require { didRun = true }
|
||||
|
||||
#expect(didRun)
|
||||
#expect(!gate.isPresentingSignIn)
|
||||
}
|
||||
|
||||
@Test("require mit .signedOut öffnet Sheet, Action wartet")
|
||||
func defersWhenSignedOut() {
|
||||
let mocked = makeMockedAuth()
|
||||
mocked.auth.bootstrap() // → .signedOut
|
||||
let gate = ManaAuthGate(auth: mocked.auth)
|
||||
|
||||
var didRun = false
|
||||
gate.require { didRun = true }
|
||||
|
||||
#expect(!didRun)
|
||||
#expect(gate.isPresentingSignIn)
|
||||
}
|
||||
|
||||
@Test("require mit .guest öffnet Sheet, Action wartet")
|
||||
func defersWhenGuest() throws {
|
||||
let mocked = makeMockedAuth()
|
||||
_ = try mocked.auth.enterGuestMode()
|
||||
let gate = ManaAuthGate(auth: mocked.auth)
|
||||
|
||||
var didRun = false
|
||||
gate.require { didRun = true }
|
||||
|
||||
#expect(!didRun)
|
||||
#expect(gate.isPresentingSignIn)
|
||||
}
|
||||
|
||||
@Test("resolvePending läuft, sobald Status auf .signedIn wechselt")
|
||||
func resolvesPendingAfterSignIn() async throws {
|
||||
let mocked = makeMockedAuth()
|
||||
mocked.auth.bootstrap()
|
||||
let gate = ManaAuthGate(auth: mocked.auth)
|
||||
|
||||
var didRun = false
|
||||
gate.require { didRun = true }
|
||||
#expect(!didRun)
|
||||
#expect(gate.isPresentingSignIn)
|
||||
|
||||
await signInMockedAuth(mocked)
|
||||
gate.resolvePending()
|
||||
|
||||
#expect(didRun)
|
||||
#expect(!gate.isPresentingSignIn)
|
||||
}
|
||||
|
||||
@Test("resolvePending ist no-op wenn noch nicht signedIn")
|
||||
func resolvePendingNoOpWhenNotSignedIn() {
|
||||
let mocked = makeMockedAuth()
|
||||
mocked.auth.bootstrap()
|
||||
let gate = ManaAuthGate(auth: mocked.auth)
|
||||
|
||||
var didRun = false
|
||||
gate.require { didRun = true }
|
||||
gate.resolvePending()
|
||||
|
||||
#expect(!didRun)
|
||||
#expect(gate.isPresentingSignIn)
|
||||
}
|
||||
|
||||
@Test("cancelPending verwirft Action — danach kein Lauf bei resolvePending")
|
||||
func cancelDiscardsPending() async throws {
|
||||
let mocked = makeMockedAuth()
|
||||
mocked.auth.bootstrap()
|
||||
let gate = ManaAuthGate(auth: mocked.auth)
|
||||
|
||||
var didRun = false
|
||||
gate.require { didRun = true }
|
||||
gate.cancelPending()
|
||||
|
||||
await signInMockedAuth(mocked)
|
||||
gate.resolvePending()
|
||||
|
||||
#expect(!didRun)
|
||||
}
|
||||
|
||||
@Test("lastReason wird gesetzt")
|
||||
func lastReasonIsRecorded() {
|
||||
let mocked = makeMockedAuth()
|
||||
mocked.auth.bootstrap()
|
||||
let gate = ManaAuthGate(auth: mocked.auth)
|
||||
|
||||
gate.require(reason: "ai-generate") {}
|
||||
#expect(gate.lastReason == "ai-generate")
|
||||
}
|
||||
|
||||
// Die async-Overload (`Task { await action() }`) ist trivial und
|
||||
// ein dedizierter Test über `Task.sleep` ist timing-fragil.
|
||||
// Die sync-Variante prüft die State-Maschine vollständig; die
|
||||
// async-Variante teilt sich Pending/Resolve-Logik mit der sync-
|
||||
// Variante (siehe ManaAuthGate.swift).
|
||||
}
|
||||
|
||||
/// Loggt den `MockedAuth` über den echten signIn-Pfad ein. Wird genutzt
|
||||
/// statt direktem `persistSession`, weil letzteres `internal` zu ManaCore
|
||||
/// ist und aus den ui-Tests nicht erreichbar.
|
||||
@MainActor
|
||||
func signInMockedAuth(_ mocked: MockedAuth, email: String = "u@x.de") async {
|
||||
// Gültiger HS256-Header.payload (exp 2_000_000_000) — JWT.expiry()
|
||||
// läuft nicht in den Refresh-Pfad.
|
||||
let access = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSIsImV4cCI6MjAwMDAwMDAwMH0.sig"
|
||||
mocked.setHandler { _ in
|
||||
(200, Data(#"{"accessToken":"\#(access)","refreshToken":"r"}"#.utf8))
|
||||
}
|
||||
await mocked.auth.signIn(email: email, password: "Aa-123456789")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue