import Foundation import Testing @testable import ManaCore @Suite("AuthClient Guest-Mode + Resilience") @MainActor struct AuthClientGuestAndResilienceTests { // MARK: - enterGuestMode / currentGuestId @Test("enterGuestMode aus signedOut erzeugt UUID und setzt .guest") func enterGuestModeFromSignedOut() throws { let mocked = makeMockedAuth() mocked.auth.bootstrap() #expect(mocked.auth.status == .signedOut) let id = try mocked.auth.enterGuestMode() #expect(!id.isEmpty) #expect(mocked.auth.status == .guest(id: id)) #expect(mocked.auth.currentGuestId() == id) } @Test("enterGuestMode ist idempotent") func enterGuestModeIdempotent() throws { let mocked = makeMockedAuth() let first = try mocked.auth.enterGuestMode() let second = try mocked.auth.enterGuestMode() #expect(first == second) #expect(mocked.auth.status == .guest(id: first)) } @Test("enterGuestMode stört aktive Session nicht") func enterGuestModeKeepsSignedIn() throws { let mocked = makeMockedAuth() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") #expect(mocked.auth.status == .signedIn(email: "u@x.de")) let id = try mocked.auth.enterGuestMode() #expect(mocked.auth.status == .signedIn(email: "u@x.de")) #expect(mocked.auth.currentGuestId() == id) } @Test("clearGuestId aus .guest fällt auf .signedOut") func clearGuestIdFromGuest() throws { let mocked = makeMockedAuth() _ = try mocked.auth.enterGuestMode() mocked.auth.clearGuestId() #expect(mocked.auth.status == .signedOut) #expect(mocked.auth.currentGuestId() == nil) } @Test("clearGuestId aus .signedIn behält Status") func clearGuestIdKeepsSignedIn() throws { let mocked = makeMockedAuth() _ = try mocked.auth.enterGuestMode() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.auth.clearGuestId() #expect(mocked.auth.status == .signedIn(email: "u@x.de")) #expect(mocked.auth.currentGuestId() == nil) } // MARK: - bootstrap @Test("bootstrap erkennt nur-Guest-Keychain als .guest") func bootstrapDetectsGuest() throws { let mocked = makeMockedAuth() _ = try mocked.auth.enterGuestMode() mocked.auth.bootstrap() if case let .guest(id) = mocked.auth.status { #expect(!id.isEmpty) } else { Issue.record("Expected .guest after bootstrap, got \(mocked.auth.status)") } } @Test("bootstrap priorisiert Session über Guest") func bootstrapPrioritisesSession() throws { let mocked = makeMockedAuth() _ = try mocked.auth.enterGuestMode() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.auth.bootstrap() #expect(mocked.auth.status == .signedIn(email: "u@x.de")) } // MARK: - signOut(keepGuestMode:) @Test("signOut Default löscht alles inkl. Guest-ID") func signOutDefaultClearsGuest() async throws { let mocked = makeMockedAuth() _ = try mocked.auth.enterGuestMode() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.setHandler { _ in (200, Data()) } await mocked.auth.signOut() #expect(mocked.auth.status == .signedOut) #expect(mocked.auth.currentGuestId() == nil) } @Test("signOut(keepGuestMode: true) behält existierende Guest-ID") func signOutKeepsExistingGuest() async throws { let mocked = makeMockedAuth() let id = try mocked.auth.enterGuestMode() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.setHandler { _ in (200, Data()) } await mocked.auth.signOut(keepGuestMode: true) #expect(mocked.auth.status == .guest(id: id)) #expect(mocked.auth.currentGuestId() == id) } @Test("signOut(keepGuestMode: true) erzeugt neue Guest-ID wenn keine existiert") func signOutCreatesGuestWhenMissing() async throws { let mocked = makeMockedAuth() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.setHandler { _ in (200, Data()) } await mocked.auth.signOut(keepGuestMode: true) if case let .guest(id) = mocked.auth.status { #expect(!id.isEmpty) #expect(mocked.auth.currentGuestId() == id) } else { Issue.record("Expected .guest after signOut(keepGuestMode:), got \(mocked.auth.status)") } } // MARK: - refreshAccessToken Resilience @Test("refresh 503 wirft serviceUnavailable, behält Session") func refreshKeepsSessionOn503() async throws { let mocked = makeMockedAuth() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.setHandler { _ in (503, Data(#"{"error":"SERVICE_UNAVAILABLE","status":503}"#.utf8)) } do { _ = try await mocked.auth.refreshAccessToken() Issue.record("Expected throw on 503") } catch let err as AuthError { #expect(err == .serviceUnavailable) } #expect(mocked.auth.status == .signedIn(email: "u@x.de")) } @Test("refresh 500 wirft serverInternal, behält Session") func refreshKeepsSessionOn500() async throws { let mocked = makeMockedAuth() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.setHandler { _ in (500, Data(#"{"error":"INTERNAL","status":500}"#.utf8)) } do { _ = try await mocked.auth.refreshAccessToken() Issue.record("Expected throw on 500") } catch let err as AuthError { #expect(err == .serverInternal) } #expect(mocked.auth.status == .signedIn(email: "u@x.de")) } @Test("refresh 429 wirft rateLimited, behält Session") func refreshKeepsSessionOnRateLimit() async throws { let mocked = makeMockedAuth() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.setHandler { _ in (429, Data(#"{"error":"RATE_LIMITED","retryAfterSec":30,"status":429}"#.utf8)) } do { _ = try await mocked.auth.refreshAccessToken() Issue.record("Expected throw on 429") } catch let err as AuthError { if case let .rateLimited(retryAfter) = err { #expect(retryAfter == 30) } else { Issue.record("Expected .rateLimited, got \(err)") } } #expect(mocked.auth.status == .signedIn(email: "u@x.de")) } @Test("refresh 401 invalidiert Session ohne Guest → .signedOut") func refreshInvalidates401NoGuest() async throws { let mocked = makeMockedAuth() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.setHandler { _ in (401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8)) } do { _ = try await mocked.auth.refreshAccessToken() Issue.record("Expected throw on 401") } catch let err as AuthError { #expect(err.invalidatesSession) } #expect(mocked.auth.status == .signedOut) } @Test("refresh 401 mit Guest-ID fällt auf .guest zurück") func refreshInvalidates401WithGuest() async throws { let mocked = makeMockedAuth() let id = try mocked.auth.enterGuestMode() try mocked.auth.persistSession(email: "u@x.de", accessToken: "a", refreshToken: "r") mocked.setHandler { _ in (401, Data(#"{"error":"UNAUTHORIZED","status":401}"#.utf8)) } _ = try? await mocked.auth.refreshAccessToken() #expect(mocked.auth.status == .guest(id: id)) #expect(mocked.auth.currentGuestId() == id) } // MARK: - AuthError.invalidatesSession @Test("invalidatesSession unterscheidet Session-Tot vs. transient") func invalidatesSessionPartitioning() { // Tot — Session muss weg #expect(AuthError.invalidCredentials.invalidatesSession) #expect(AuthError.unauthorized.invalidatesSession) #expect(AuthError.tokenExpired.invalidatesSession) #expect(AuthError.tokenInvalid.invalidatesSession) // Transient — Session bleibt #expect(!AuthError.serviceUnavailable.invalidatesSession) #expect(!AuthError.serverInternal.invalidatesSession) #expect(!AuthError.networkFailure("offline").invalidatesSession) #expect(!AuthError.rateLimited(retryAfter: 30).invalidatesSession) #expect(!AuthError.accountLocked(retryAfter: nil).invalidatesSession) } }