250 lines
8.5 KiB
Swift
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 {}
|
||
|
|
}
|