Introduce a user session manager (#6295)

This commit is contained in:
Mauro
2026-10-09 14:07:39 +02:00
committed by GitHub
parent 65bab49f39
commit 60db364436
26 changed files with 594 additions and 66 deletions
@@ -38,6 +38,7 @@ struct ChatsTabFlowCoordinatorTests {
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
let flowParameters = CommonFlowParameters(userSession: userSession,
availableSessionsPublisher: .init([.init(userSession: userSession)]),
bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: timelineControllerFactory,
@@ -460,6 +460,19 @@ final class HomeScreenViewModelTests {
try await deferred.fulfill()
}
@Test
func multiAccountAnnouncementIsMarkedSeenWithSeveralAccounts() async throws {
userSettings.app.multiAccountEnabled = true
userSettings.app.hasSeenMultiAccountAnnouncement = false
setupViewModel(otherAccountUserIDs: ["@other:client.com"])
let deferred = deferFailure(context.$viewState, timeout: .seconds(1)) { $0.bindings.isPresentingMultiAccountAnnouncement }
context.send(viewAction: .screenAppeared)
try await deferred.fulfill()
#expect(userSettings.app.hasSeenMultiAccountAnnouncement)
}
// MARK: - Helpers
enum InviteType { case rooms, spaces }
@@ -551,7 +564,7 @@ final class HomeScreenViewModelTests {
return (roomListSubject, stateSubject)
}
private func setupViewModel(securityStatePublisher: CurrentValuePublisher<SessionSecurityState, Never>? = nil, invites: InviteType? = nil, roomSummaryProvider: RoomSummaryProviderMock? = nil) {
private func setupViewModel(otherAccountUserIDs: [String] = [], securityStatePublisher: CurrentValuePublisher<SessionSecurityState, Never>? = nil, invites: InviteType? = nil, roomSummaryProvider: RoomSummaryProviderMock? = nil) {
cancellables.removeAll()
var rooms: [RoomSummary] = .mockRooms
@@ -595,7 +608,10 @@ final class HomeScreenViewModelTests {
notificationManager = NotificationManagerMock()
let sessionDetails = [UserSessionDetails(userSession: userSession)] + otherAccountUserIDs.map { UserSessionDetails(userID: $0) }
viewModel = HomeScreenViewModel(userSession: userSession,
availableSessionsPublisher: .init(sessionDetails),
selectedRoomPublisher: CurrentValueSubject<String?, Never>(nil).asCurrentValuePublisher(),
analyticsService: AnalyticsServiceMock(.init()),
bugReportService: BugReportServiceMock(.init()),
@@ -522,7 +522,9 @@ final class RoomFlowCoordinatorTests {
"1"
}
let flowParameters = CommonFlowParameters(userSession: UserSessionMock(.init(clientProxy: clientProxy, userSettings: userSettings)),
let userSession = UserSessionMock(.init(clientProxy: clientProxy, userSettings: userSettings))
let flowParameters = CommonFlowParameters(userSession: userSession,
availableSessionsPublisher: .init([.init(userSession: userSession)]),
bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: timelineControllerFactory,
@@ -54,6 +54,7 @@ struct RoomMembersFlowCoordinatorTests {
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
let flowParameters = CommonFlowParameters(userSession: userSession,
availableSessionsPublisher: .init([.init(userSession: userSession)]),
bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
@@ -51,6 +51,7 @@ struct UserSessionFlowCoordinatorTests {
let userSession = UserSessionMock(.init(clientProxy: clientProxy))
let flowParameters = CommonFlowParameters(userSession: userSession,
availableSessionsPublisher: .init([.init(userSession: userSession)]),
bugReportService: BugReportServiceMock(.init()),
elementCallService: ElementCallServiceMock(.init()),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
@@ -0,0 +1,119 @@
//
// Copyright 2026 Element Creations Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
// Please see LICENSE files in the repository root for full details.
//
@testable import ElementX
import Testing
@MainActor
struct UserSessionManagerTests {
let userSessionStore = UserSessionStoreMock()
let appSettings = AppSettings.volatile()
@Test
func ordersAccountsByWhenTheyWereLastSelected() {
appSettings.userSettings(for: "@alice:matrix.org").account.lastSelectedDate = .now.addingTimeInterval(-60)
appSettings.userSettings(for: "@bob:matrix.org").account.lastSelectedDate = .now
// Accounts that were never selected (e.g. after upgrading from a single account) come last.
let manager = makeManager(userIDs: ["@dave:matrix.org", "@alice:matrix.org", "@carol:matrix.org", "@bob:matrix.org"])
#expect(manager.userIDs == ["@bob:matrix.org", "@alice:matrix.org", "@carol:matrix.org", "@dave:matrix.org"])
}
@Test
func restoreFallsBackToTheNextAccount() async {
let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"])
// Alice already has a live session, e.g. when restoring again after clearing the cache.
manager.add(makeUserSession(userID: "@alice:matrix.org"))
let bob = makeUserSession(userID: "@bob:matrix.org")
userSessionStore.restoreUserSessionUserIDClosure = { userID in
userID == "@bob:matrix.org" ? .success(bob) : .failure(.failedRestoringLogin)
}
guard case .success(let userSession) = await manager.restoreActiveSession() else {
Issue.record("Restoring should fall back to Bob.")
return
}
#expect(userSession.clientProxy.userID == "@bob:matrix.org")
#expect(manager.userIDs == ["@bob:matrix.org"])
#expect(manager.session(for: "@alice:matrix.org") == nil)
// Nothing can use the session until it's added, e.g. while migrations run.
#expect(manager.activeSession == nil)
}
@Test
func restoreFailsWhenNoAccountCanBeRestored() async {
let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"])
userSessionStore.restoreUserSessionUserIDReturnValue = .failure(.failedRestoringLogin)
guard case .failure(.failedRestoringSessions) = await manager.restoreActiveSession() else {
Issue.record("Restoring should fail once every account has failed.")
return
}
#expect(userSessionStore.restoreUserSessionUserIDReceivedInvocations == ["@alice:matrix.org", "@bob:matrix.org"])
#expect(manager.userIDs.isEmpty)
}
@Test
func addRegistersTheSession() {
let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"])
// A known account keeps its place, e.g. after clearing the cache.
let bob = makeUserSession(userID: "@bob:matrix.org")
manager.add(bob)
#expect(manager.userIDs == ["@alice:matrix.org", "@bob:matrix.org"])
#expect((manager.session(for: "@bob:matrix.org") as? UserSessionMock) === bob)
#expect(manager.activeSession == nil)
// A new account becomes the active one.
let carol = makeUserSession(userID: "@carol:matrix.org")
manager.add(carol)
#expect(manager.userIDs == ["@carol:matrix.org", "@alice:matrix.org", "@bob:matrix.org"])
#expect(appSettings.userSettings(for: "@carol:matrix.org").account.lastSelectedDate != nil)
#expect((manager.activeSession as? UserSessionMock) === carol)
#expect(manager.sessionsPublisher.value.map(\.clientProxy.userID) == ["@carol:matrix.org", "@bob:matrix.org"])
}
@Test
func addReplacesTheSessionOfAKnownAccount() {
let manager = makeManager(userIDs: ["@alice:matrix.org", "@bob:matrix.org"])
manager.add(makeUserSession(userID: "@alice:matrix.org"))
// E.g. restoring again after clearing the cache, or signing in again after a soft logout.
let newAlice = makeUserSession(userID: "@alice:matrix.org")
manager.add(newAlice)
#expect(manager.userIDs == ["@alice:matrix.org", "@bob:matrix.org"])
#expect((manager.activeSession as? UserSessionMock) === newAlice)
}
@Test
func removeDeletesTheAccount() {
let manager = makeManager(userIDs: ["@alice:matrix.org"])
let alice = makeUserSession(userID: "@alice:matrix.org")
manager.add(alice)
manager.remove(userID: "@alice:matrix.org")
#expect((userSessionStore.logoutUserSessionReceivedUserSession as? UserSessionMock) === alice)
#expect(manager.userIDs.isEmpty)
#expect(manager.activeSession == nil)
}
// MARK: - Helpers
private func makeManager(userIDs: [String]) -> UserSessionManager {
userSessionStore.userIDs = userIDs
return UserSessionManager(userSessionStore: userSessionStore, appSettings: appSettings)
}
private func makeUserSession(userID: String) -> UserSessionMock {
UserSessionMock(.init(clientProxy: ClientProxyMock(.init(userID: userID))))
}
}
+10 -9
View File
@@ -46,8 +46,8 @@ struct UserSessionStoreTests {
// MARK: - Restoration
@Test
func restoreWithoutCredentials() async {
guard case .failure(.missingCredentials) = await store.restoreUserSession() else {
func restoreUnknownAccount() async {
guard case .failure(.unknownAccount) = await store.restoreUserSession(userID: "@alice:matrix.org") else {
Issue.record("Restoration should fail when there are no credentials.")
return
}
@@ -60,7 +60,7 @@ struct UserSessionStoreTests {
keychainController.restorationTokensReturnValue = [credentials]
// When restoring the session.
guard case .failure(.failedRestoringLogin) = await store.restoreUserSession() else {
guard case .failure(.failedRestoringLogin) = await store.restoreUserSession(userID: credentials.userID) else {
Issue.record("Restoration should fail when the non-transient user data is missing.")
return
}
@@ -79,7 +79,7 @@ struct UserSessionStoreTests {
clientFactory.makeAppClientCredentialsClientSessionDelegateAppSettingsAppHooksThrowableError = TestError.generic
// When restoring the session.
guard case .failure(.failedRestoringLogin) = await store.restoreUserSession() else {
guard case .failure(.failedRestoringLogin) = await store.restoreUserSession(userID: credentials.userID) else {
Issue.record("Restoration should fail when the client can't be created.")
return
}
@@ -90,18 +90,19 @@ struct UserSessionStoreTests {
@Test
func restoreSucceeds() async throws {
// Given valid session data and a client factory that returns a client.
// Given valid session data for Alice, stored after another account.
let sessionDirectories = try makeValidSessionDirectories()
defer { try? sessionDirectories.delete() }
keychainController.restorationTokensReturnValue = [makeCredentials(sessionDirectories: sessionDirectories)]
keychainController.restorationTokensReturnValue = [makeCredentials(sessionDirectories: .init(), userID: "@bob:matrix.org"),
makeCredentials(sessionDirectories: sessionDirectories)]
// When restoring the session.
guard case .success(let userSession) = await store.restoreUserSession() else {
// When restoring Alice's session.
guard case .success(let userSession) = await store.restoreUserSession(userID: "@alice:matrix.org") else {
Issue.record("Restoration should succeed.")
return
}
// Then a user session should be built for the restored client.
// Then a user session should be built for Alice's client.
#expect(userSession.clientProxy.userID == "@alice:matrix.org")
}