import XCTest @testable import IDPGlobal @MainActor final class AppViewModelTests: XCTestCase { func testBootstrapRestoresPersistedState() async { let session = makeSession() let profile = makeProfile() let snapshot = makeSnapshot(profile: profile) let store = InMemoryAppStateStore( state: PersistedAppState( session: session, profile: profile, requests: snapshot.requests, notifications: snapshot.notifications ) ) let service = StubService( bootstrapContext: BootstrapContext(suggestedPairingPayload: "idp.global://pair?token=fresh-token&origin=code.foss.global&device=Fresh%20Browser"), signInResult: SignInResult(session: session, snapshot: snapshot), dashboardSnapshot: snapshot ) let coordinator = StubNotificationCoordinator(status: .allowed) let model = AppViewModel( service: service, notificationCoordinator: coordinator, appStateStore: store, launchArguments: [] ) await model.bootstrap() XCTAssertEqual(model.session, session) XCTAssertEqual(model.profile, profile) XCTAssertEqual(model.requests.map(\.id), snapshot.requests.sorted { $0.createdAt > $1.createdAt }.map(\.id)) XCTAssertEqual(model.notifications.map(\.id), snapshot.notifications.sorted { $0.sentAt > $1.sentAt }.map(\.id)) XCTAssertEqual(model.manualPairingPayload, session.pairingCode) XCTAssertEqual(model.suggestedPairingPayload, "idp.global://pair?token=fresh-token&origin=code.foss.global&device=Fresh%20Browser") XCTAssertEqual(model.notificationPermission, .allowed) } func testSignInPersistsAuthenticatedState() async { let session = makeSession() let profile = makeProfile() let snapshot = makeSnapshot(profile: profile) let store = InMemoryAppStateStore() let service = StubService( bootstrapContext: BootstrapContext(suggestedPairingPayload: session.pairingCode), signInResult: SignInResult(session: session, snapshot: snapshot), dashboardSnapshot: snapshot ) let model = AppViewModel( service: service, notificationCoordinator: StubNotificationCoordinator(status: .allowed), appStateStore: store, launchArguments: [] ) await model.signIn(with: session.pairingCode, transport: .preview) XCTAssertEqual(model.session, session) XCTAssertEqual(store.storedState?.session, session) XCTAssertEqual(store.storedState?.profile, profile) XCTAssertEqual(store.storedState?.requests.map(\.id), snapshot.requests.sorted { $0.createdAt > $1.createdAt }.map(\.id)) XCTAssertEqual(store.storedState?.notifications.map(\.id), snapshot.notifications.sorted { $0.sentAt > $1.sentAt }.map(\.id)) } func testSignOutClearsPersistedState() async { let session = makeSession() let profile = makeProfile() let snapshot = makeSnapshot(profile: profile) let store = InMemoryAppStateStore( state: PersistedAppState( session: session, profile: profile, requests: snapshot.requests, notifications: snapshot.notifications ) ) let model = AppViewModel( service: StubService( bootstrapContext: BootstrapContext(suggestedPairingPayload: session.pairingCode), signInResult: SignInResult(session: session, snapshot: snapshot), dashboardSnapshot: snapshot ), notificationCoordinator: StubNotificationCoordinator(status: .allowed), appStateStore: store, launchArguments: [] ) await model.bootstrap() model.signOut() XCTAssertNil(model.session) XCTAssertNil(model.profile) XCTAssertTrue(store.didClear) XCTAssertNil(store.storedState) } private func makeSession() -> AuthSession { AuthSession( deviceName: "Safari on Berlin MBP", originHost: "code.foss.global", pairedAt: Date(timeIntervalSince1970: 1_700_000_000), tokenPreview: "berlin", pairingCode: "idp.global://pair?token=swiftapp-demo-berlin&origin=code.foss.global&device=Safari%20on%20Berlin%20MBP", pairingTransport: .preview ) } private func makeProfile() -> MemberProfile { MemberProfile( name: "Phil Kunz", handle: "phil@idp.global", organization: "idp.global", deviceCount: 4, recoverySummary: "Recovery kit healthy with 2 of 3 backup paths verified." ) } private func makeSnapshot(profile: MemberProfile) -> DashboardSnapshot { DashboardSnapshot( profile: profile, requests: [ ApprovalRequest( title: "Later request", subtitle: "Newer", source: "later.idp.global", createdAt: Date(timeIntervalSince1970: 200), kind: .signIn, risk: .routine, scopes: ["proof:basic"], status: .pending ), ApprovalRequest( title: "Earlier request", subtitle: "Older", source: "earlier.idp.global", createdAt: Date(timeIntervalSince1970: 100), kind: .elevatedAction, risk: .elevated, scopes: ["proof:high"], status: .approved ) ], notifications: [ AppNotification( title: "Older notification", message: "Oldest", sentAt: Date(timeIntervalSince1970: 100), kind: .system, isUnread: false ), AppNotification( title: "Newer notification", message: "Newest", sentAt: Date(timeIntervalSince1970: 200), kind: .security, isUnread: true ) ] ) } } private final class InMemoryAppStateStore: AppStateStoring { var storedState: PersistedAppState? var didClear = false init(state: PersistedAppState? = nil) { storedState = state } func load() -> PersistedAppState? { storedState } func save(_ state: PersistedAppState) { storedState = state didClear = false } func clear() { storedState = nil didClear = true } } private actor StubService: IDPServicing { private let bootstrapContext: BootstrapContext private let signInResult: SignInResult private let dashboardSnapshot: DashboardSnapshot init(bootstrapContext: BootstrapContext, signInResult: SignInResult, dashboardSnapshot: DashboardSnapshot) { self.bootstrapContext = bootstrapContext self.signInResult = signInResult self.dashboardSnapshot = dashboardSnapshot } func bootstrap() async throws -> BootstrapContext { bootstrapContext } func signIn(with request: PairingAuthenticationRequest) async throws -> SignInResult { signInResult } func identify(with request: PairingAuthenticationRequest) async throws -> DashboardSnapshot { dashboardSnapshot } func refreshDashboard() async throws -> DashboardSnapshot { dashboardSnapshot } func approveRequest(id: UUID) async throws -> DashboardSnapshot { dashboardSnapshot } func rejectRequest(id: UUID) async throws -> DashboardSnapshot { dashboardSnapshot } func simulateIncomingRequest() async throws -> DashboardSnapshot { dashboardSnapshot } func markNotificationRead(id: UUID) async throws -> DashboardSnapshot { dashboardSnapshot } } private final class StubNotificationCoordinator: NotificationCoordinating { private let status: NotificationPermissionState init(status: NotificationPermissionState) { self.status = status } func authorizationStatus() async -> NotificationPermissionState { status } func requestAuthorization() async throws -> NotificationPermissionState { status } func scheduleTestNotification(title: String, body: String) async throws {} }