Files
swiftapp/swift/Tests/AppViewModelTests.swift
Jürgen Kunz a6939453f8
Some checks failed
CI / test (push) Has been cancelled
Adopt root-level tsswift app layout
Move the app payload under swift/ while keeping git, package.json, and .smartconfig.json at the repo root. This standardizes the Swift app setup so build, test, run, and watch workflows match the other repos.
2026-04-19 01:21:43 +02:00

250 lines
8.5 KiB
Swift

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 {}
}