// // LaunchSessionTests.swift // // Copyright 2026 Brendan Szymanski // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // // SPDX-License-Identifier: GPL-3.0-or-later // import Foundation import LuminateCore import LuminateUI import Synchronization import Testing @testable import Luminate /// Exercises the launch-time token verification gate in isolation from any real network call. @Suite @MainActor struct LaunchSessionTests { @Test("A launch with no stored token skips verification") func noStoredTokenSkipsVerification() async { let preferences = Preferences(store: EphemeralPreferenceStore()) let callCount = Mutex(0) let session = LaunchSession(phase: .resolved, preferences: preferences) { callCount.withLock { $0 += 1 } } #expect(session.phase == .resolved) await session.verifyStoredToken() #expect(callCount.withLock { $0 } == 0) } @Test("A valid token keeps the session and resolves") func validTokenResolves() async { let preferences = Preferences(store: EphemeralPreferenceStore()) preferences[.accessToken] = "tok" let session = LaunchSession(phase: .verifying, preferences: preferences) {} #expect(session.phase == .verifying) await session.verifyStoredToken() #expect(session.phase == .resolved) #expect(preferences[.accessToken] == "tok") } @Test("A rejected token is cleared and returns the user to onboarding") func rejectedTokenIsCleared() async { let preferences = Preferences(store: EphemeralPreferenceStore()) preferences[.accessToken] = "tok" let session = LaunchSession(phase: .verifying, preferences: preferences) { throw JellyfinClientError.unauthorized } await session.verifyStoredToken() #expect(session.phase == .resolved) #expect(preferences[.accessToken] == nil) } @Test("An unreachable server keeps the token and offers a retry") func unreachableServerOffersRetry() async { let preferences = Preferences(store: EphemeralPreferenceStore()) preferences[.accessToken] = "tok" let session = LaunchSession(phase: .verifying, preferences: preferences) { throw URLError(.cannotConnectToHost) } await session.verifyStoredToken() guard case .unreachable = session.phase else { Issue.record("Expected .unreachable, got \(session.phase)") return } #expect(session.failureMessage != nil) #expect(preferences[.accessToken] == "tok") } @Test("Retrying after an outage re-runs verification") func retryReRunsVerification() async { let preferences = Preferences(store: EphemeralPreferenceStore()) preferences[.accessToken] = "tok" let callCount = Mutex(0) let session = LaunchSession(phase: .verifying, preferences: preferences) { let attempt = callCount.withLock { count -> Int in count += 1 return count } if attempt == 1 { throw URLError(.cannotConnectToHost) } } await session.verifyStoredToken() guard case .unreachable = session.phase else { Issue.record("Expected .unreachable after the first attempt, got \(session.phase)") return } await session.retry() #expect(session.phase == .resolved) #expect(preferences[.accessToken] == "tok") } @Test("Signing out from the retry screen clears the token") func signOutFromRetryScreenClearsToken() async { let preferences = Preferences(store: EphemeralPreferenceStore()) preferences[.accessToken] = "tok" let session = LaunchSession(phase: .verifying, preferences: preferences) { throw URLError(.cannotConnectToHost) } await session.verifyStoredToken() guard case .unreachable = session.phase else { Issue.record("Expected .unreachable, got \(session.phase)") return } session.signOut() #expect(session.phase == .resolved) #expect(preferences[.accessToken] == nil) } @Test("Launching with no stored token skips the probe entirely") func launchWithNoStoredTokenSkipsProbe() { let preferences = Preferences(store: EphemeralPreferenceStore()) let callCount = Mutex(0) let session = LaunchSession.launch(preferences: preferences) { callCount.withLock { $0 += 1 } } #expect(session.phase == .resolved) #expect(callCount.withLock { $0 } == 0) } @Test("Launching with a valid token resolves synchronously") func launchWithValidTokenResolves() { let preferences = Preferences(store: EphemeralPreferenceStore()) preferences[.accessToken] = "tok" let session = LaunchSession.launch(preferences: preferences) {} #expect(session.phase == .resolved) #expect(preferences[.accessToken] == "tok") } @Test("Launching with a rejected token clears it synchronously") func launchWithRejectedTokenClearsIt() { let preferences = Preferences(store: EphemeralPreferenceStore()) preferences[.accessToken] = "tok" let session = LaunchSession.launch(preferences: preferences) { throw JellyfinClientError.unauthorized } #expect(session.phase == .resolved) #expect(preferences[.accessToken] == nil) } @Test("Launching against an unreachable server resolves to a retry phase") func launchAgainstUnreachableServerResolvesToRetry() { let preferences = Preferences(store: EphemeralPreferenceStore()) preferences[.accessToken] = "tok" let session = LaunchSession.launch(preferences: preferences) { throw URLError(.cannotConnectToHost) } guard case .unreachable = session.phase else { Issue.record("Expected .unreachable, got \(session.phase)") return } #expect(preferences[.accessToken] == "tok") } }