Improve user session overview tests

* Add sessions overview UI tests
* Expose static methods from the UserSession name and lastActivity formatters; cleaned up the UserSessionsOverview a bit
* Add UserSessionsOverviewViewModel unit tests
* Add UserSessionsOverviewService unit tests
This commit is contained in:
Stefan Ceriu 2022-09-30 13:49:16 +03:00 committed by GitHub
parent f4da3e2448
commit 52c4ff65db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 650 additions and 143 deletions

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotTests - RiotTests
gatherCoverageData: true
coverageTargets:
- Riot
targets: targets:
Riot: Riot:

View file

@ -17,7 +17,7 @@
import Foundation import Foundation
/// Enables to build last activity date string /// Enables to build last activity date string
class UserSessionLastActivityFormatter { enum UserSessionLastActivityFormatter {
private static var lastActivityDateFormatter: DateFormatter = { private static var lastActivityDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
dateFormatter.locale = Locale.current dateFormatter.locale = Locale.current
@ -28,9 +28,9 @@ class UserSessionLastActivityFormatter {
}() }()
/// Session last activity string /// Session last activity string
func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String { static func lastActivityDateString(from lastActivityTimestamp: TimeInterval) -> String {
let date = Date(timeIntervalSince1970: lastActivityTimestamp) let date = Date(timeIntervalSince1970: lastActivityTimestamp)
return UserSessionLastActivityFormatter.lastActivityDateFormatter.string(from: date) return Self.lastActivityDateFormatter.string(from: date)
} }
} }

View file

@ -17,9 +17,9 @@
import Foundation import Foundation
/// Enables to build user session name /// Enables to build user session name
class UserSessionNameFormatter { enum UserSessionNameFormatter {
/// Session name with client name and session display name /// Session name with client name and session display name
func sessionName(deviceType: DeviceType, sessionDisplayName: String?) -> String { static func sessionName(deviceType: DeviceType, sessionDisplayName: String?) -> String {
let sessionName: String let sessionName: String
let clientName = deviceType.name let clientName = deviceType.name

View file

@ -109,12 +109,14 @@ struct UserSessionCardView: View {
.buttonStyle(PrimaryActionButtonStyle()) .buttonStyle(PrimaryActionButtonStyle())
.padding(.top, 4) .padding(.top, 4)
.padding(.bottom, 3) .padding(.bottom, 3)
.accessibilityIdentifier("userSessionCardVerifyButton")
} }
if viewData.isCurrentSessionDisplayMode { if viewData.isCurrentSessionDisplayMode {
Text(VectorL10n.userSessionViewDetails) Text(VectorL10n.userSessionViewDetails)
.font(theme.fonts.body) .font(theme.fonts.body)
.foregroundColor(theme.colors.accent) .foregroundColor(theme.colors.accent)
.accessibilityIdentifier("userSessionCardViewDetails")
} }
} }
.padding(24) .padding(24)

View file

@ -18,9 +18,6 @@ import Foundation
/// View data for UserSessionCardView /// View data for UserSessionCardView
struct UserSessionCardViewData { struct UserSessionCardViewData {
private static let userSessionNameFormatter = UserSessionNameFormatter()
private static let lastActivityDateFormatter = UserSessionLastActivityFormatter()
var id: String { var id: String {
sessionId sessionId
} }
@ -48,13 +45,13 @@ struct UserSessionCardViewData {
lastSeenIP: String?, lastSeenIP: String?,
isCurrentSessionDisplayMode: Bool = false) { isCurrentSessionDisplayMode: Bool = false) {
self.sessionId = sessionId self.sessionId = sessionId
sessionName = Self.userSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName) sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName)
self.isVerified = isVerified self.isVerified = isVerified
var lastActivityDateString: String? var lastActivityDateString: String?
if let lastActivityTimestamp = lastActivityTimestamp { if let lastActivityTimestamp = lastActivityTimestamp {
lastActivityDateString = Self.lastActivityDateFormatter.lastActivityDateString(from: lastActivityTimestamp) lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityTimestamp)
} }
self.lastActivityDateString = lastActivityDateString self.lastActivityDateString = lastActivityDateString

View file

@ -37,7 +37,8 @@ final class UserSessionsOverviewCoordinator: Coordinator, Presentable {
init(parameters: UserSessionsOverviewCoordinatorParameters) { init(parameters: UserSessionsOverviewCoordinatorParameters) {
self.parameters = parameters self.parameters = parameters
service = UserSessionsOverviewService(mxSession: parameters.session) let dataProvider = UserSessionsDataProvider(session: parameters.session)
service = UserSessionsOverviewService(dataProvider: dataProvider)
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service) viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context)) hostingViewController = VectorHostingController(rootView: UserSessionsOverview(viewModel: viewModel.context))
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingViewController) indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingViewController)

View file

@ -20,31 +20,39 @@ import SwiftUI
/// Using an enum for the screen allows you define the different state cases with /// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case. /// the relevant associated data for each case.
enum MockUserSessionsOverviewScreenState: MockScreenState, CaseIterable { enum MockUserSessionsOverviewScreenState: MockScreenState, CaseIterable {
case verifiedSession case currentSessionUnverified
case currentSessionVerified
case onlyUnverifiedSessions
case onlyInactiveSessions
case noOtherSessions
/// The associated screen /// The associated screen
var screenType: Any.Type { var screenType: Any.Type {
UserSessionsOverview.self UserSessionsOverview.self
} }
/// A list of screen state definitions
static var allCases: [MockUserSessionsOverviewScreenState] {
// Each of the presence statuses
[.verifiedSession]
}
/// Generate the view struct for the screen state. /// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) { var screenView: ([Any], AnyView) {
let service = MockUserSessionsOverviewService() var service: UserSessionsOverviewServiceProtocol?
switch self { switch self {
case .verifiedSession: case .currentSessionUnverified:
break service = MockUserSessionsOverviewService(mode: .currentSessionUnverified)
case .currentSessionVerified:
service = MockUserSessionsOverviewService(mode: .currentSessionVerified)
case .onlyUnverifiedSessions:
service = MockUserSessionsOverviewService(mode: .onlyUnverifiedSessions)
case .onlyInactiveSessions:
service = MockUserSessionsOverviewService(mode: .onlyInactiveSessions)
case .noOtherSessions:
service = MockUserSessionsOverviewService(mode: .noOtherSessions)
}
guard let service = service else {
fatalError()
} }
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service) let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
// can simulate service and viewModel actions here if needs be.
return ( return (
[service, viewModel], [service, viewModel],
AnyView(UserSessionsOverview(viewModel: viewModel.context) AnyView(UserSessionsOverview(viewModel: viewModel.context)

View file

@ -0,0 +1,50 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import MatrixSDK
class UserSessionsDataProvider: UserSessionsDataProviderProtocol {
private let session: MXSession
init(session: MXSession) {
self.session = session
}
var myDeviceId: String {
session.myDeviceId
}
var myUserId: String? {
session.myUserId
}
var activeAccounts: [MXKAccount] {
MXKAccountManager.shared().activeAccounts
}
func devices(completion: @escaping (MXResponse<[MXDevice]>) -> Void) {
session.matrixRestClient.devices(completion: completion)
}
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo {
session.crypto.device(withDeviceId: deviceId, ofUser: userId)
}
func accountData(for eventType: String) -> [AnyHashable : Any]? {
session.accountData.accountData(forEventType: eventType)
}
}

View file

@ -0,0 +1,32 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import MatrixSDK
protocol UserSessionsDataProviderProtocol {
var myDeviceId: String { get }
var myUserId: String? { get }
var activeAccounts: [MXKAccount] { get }
func devices(completion: @escaping (MXResponse<[MXDevice]>) -> Void)
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo
func accountData(for eventType: String) -> [AnyHashable: Any]?
}

View file

@ -21,12 +21,12 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
/// Delay after which session is considered inactive, 90 days /// Delay after which session is considered inactive, 90 days
private static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400 private static let inactiveSessionDurationTreshold: TimeInterval = 90 * 86400
private let mxSession: MXSession private let dataProvider: UserSessionsDataProviderProtocol
private(set) var overviewData: UserSessionsOverviewData private(set) var overviewData: UserSessionsOverviewData
init(mxSession: MXSession) { init(dataProvider: UserSessionsDataProviderProtocol) {
self.mxSession = mxSession self.dataProvider = dataProvider
overviewData = UserSessionsOverviewData(currentSession: nil, overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [], unverifiedSessions: [],
@ -39,7 +39,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
// MARK: - Public // MARK: - Public
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) { func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
mxSession.matrixRestClient.devices { response in dataProvider.devices { response in
switch response { switch response {
case .success(let devices): case .success(let devices):
self.overviewData = self.sessionsOverviewData(from: devices) self.overviewData = self.sessionsOverviewData(from: devices)
@ -61,16 +61,18 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
// MARK: - Private // MARK: - Private
private func setupInitialOverviewData() { private func setupInitialOverviewData() {
let currentSessionInfo = currentSessionInfo() guard let currentSessionInfo = getCurrentSessionInfo() else {
return
}
overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo, overviewData = UserSessionsOverviewData(currentSession: currentSessionInfo,
unverifiedSessions: [], unverifiedSessions: currentSessionInfo.isVerified ? [] : [currentSessionInfo],
inactiveSessions: [], inactiveSessions: currentSessionInfo.isActive ? [] : [currentSessionInfo],
otherSessions: []) otherSessions: [])
} }
private func currentSessionInfo() -> UserSessionInfo? { private func getCurrentSessionInfo() -> UserSessionInfo? {
guard let mainAccount = MXKAccountManager.shared().activeAccounts.first, guard let mainAccount = dataProvider.activeAccounts.first,
let device = mainAccount.device else { let device = mainAccount.device else {
return nil return nil
} }
@ -80,7 +82,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData { private func sessionsOverviewData(from devices: [MXDevice]) -> UserSessionsOverviewData {
let allSessions = devices let allSessions = devices
.sorted { $0.lastSeenTs > $1.lastSeenTs } .sorted { $0.lastSeenTs > $1.lastSeenTs }
.map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == mxSession.myDeviceId) } .map { sessionInfo(from: $0, isCurrentSession: $0.deviceId == dataProvider.myDeviceId) }
return UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first, return UserSessionsOverviewData(currentSession: allSessions.filter(\.isCurrent).first,
unverifiedSessions: allSessions.filter { !$0.isVerified }, unverifiedSessions: allSessions.filter { !$0.isVerified },
@ -92,7 +94,7 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
let isSessionVerified = deviceInfo(for: device.deviceId)?.trustLevel.isVerified ?? false let isSessionVerified = deviceInfo(for: device.deviceId)?.trustLevel.isVerified ?? false
let eventType = kMXAccountDataTypeClientInformation + "." + device.deviceId let eventType = kMXAccountDataTypeClientInformation + "." + device.deviceId
let appData = mxSession.accountData.accountData(forEventType: eventType) let appData = dataProvider.accountData(for: eventType)
var userAgent: UserAgent? var userAgent: UserAgent?
var isSessionActive = true var isSessionActive = true
@ -114,11 +116,11 @@ class UserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
} }
private func deviceInfo(for deviceId: String) -> MXDeviceInfo? { private func deviceInfo(for deviceId: String) -> MXDeviceInfo? {
guard let userId = mxSession.myUserId else { guard let userId = dataProvider.myUserId else {
return nil return nil
} }
return mxSession.crypto.device(withDeviceId: deviceId, ofUser: userId) return dataProvider.device(withDeviceId: deviceId, ofUser: userId)
} }
} }

View file

@ -17,28 +17,70 @@
import Foundation import Foundation
class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol { class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
let overviewData: UserSessionsOverviewData enum Mode {
case currentSessionUnverified
case currentSessionVerified
case onlyUnverifiedSessions
case onlyInactiveSessions
case noOtherSessions
}
private let mode: Mode
var overviewData: UserSessionsOverviewData
init(mode: Mode = .currentSessionUnverified) {
self.mode = mode
overviewData = UserSessionsOverviewData(currentSession: nil,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
}
func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) { func updateOverviewData(completion: @escaping (Result<UserSessionsOverviewData, Error>) -> Void) {
let unverifiedSessions = buildSessions(verified: false, active: true)
let inactiveSessions = buildSessions(verified: true, active: false)
switch mode {
case .noOtherSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: [],
otherSessions: [])
case .onlyUnverifiedSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions + [currentSession],
inactiveSessions: [],
otherSessions: unverifiedSessions)
case .onlyInactiveSessions:
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: [],
inactiveSessions: inactiveSessions,
otherSessions: inactiveSessions)
default:
let otherSessions = unverifiedSessions + inactiveSessions + buildSessions(verified: true, active: true)
overviewData = UserSessionsOverviewData(currentSession: currentSession,
unverifiedSessions: unverifiedSessions,
inactiveSessions: inactiveSessions,
otherSessions: otherSessions)
}
completion(.success(overviewData)) completion(.success(overviewData))
} }
func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? { func sessionForIdentifier(_ sessionId: String) -> UserSessionInfo? {
nil overviewData.otherSessions.first { $0.id == sessionId }
} }
// MARK: - Private
init() { private var currentSession: UserSessionInfo {
overviewData = UserSessionsOverviewData(currentSession: Self.allSessions.filter(\.isCurrent).first,
unverifiedSessions: Self.allSessions.filter { !$0.isVerified },
inactiveSessions: Self.allSessions.filter { !$0.isActive },
otherSessions: Self.allSessions.filter { !$0.isCurrent })
}
static let allSessions = [
UserSessionInfo(id: "alice", UserSessionInfo(id: "alice",
name: "iOS", name: "iOS",
deviceType: .mobile, deviceType: .mobile,
isVerified: false, isVerified: mode == .currentSessionVerified,
lastSeenIP: "10.0.0.10", lastSeenIP: "10.0.0.10",
lastSeenTimestamp: nil, lastSeenTimestamp: nil,
applicationName: "Element iOS", applicationName: "Element iOS",
@ -49,51 +91,54 @@ class MockUserSessionsOverviewService: UserSessionsOverviewServiceProtocol {
lastSeenIPLocation: nil, lastSeenIPLocation: nil,
deviceName: "My iPhone", deviceName: "My iPhone",
isActive: true, isActive: true,
isCurrent: true), isCurrent: true)
UserSessionInfo(id: "1", }
name: "macOS",
deviceType: .desktop, private func buildSessions(verified: Bool, active: Bool) -> [UserSessionInfo] {
isVerified: true, [UserSessionInfo(id: "1 verified: \(verified) active: \(active)",
lastSeenIP: "1.0.0.1", name: "macOS verified: \(verified) active: \(active)",
lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000, deviceType: .desktop,
applicationName: "Element MacOS", isVerified: verified,
applicationVersion: "1.0.0", lastSeenIP: "1.0.0.1",
applicationURL: nil, lastSeenTimestamp: Date().timeIntervalSince1970 - 130_000,
deviceModel: nil, applicationName: "Element MacOS",
deviceOS: "macOS 12.5.1", applicationVersion: "1.0.0",
lastSeenIPLocation: nil, applicationURL: nil,
deviceName: "My Mac", deviceModel: nil,
isActive: false, deviceOS: "macOS 12.5.1",
isCurrent: false), lastSeenIPLocation: nil,
UserSessionInfo(id: "2", deviceName: "My Mac",
name: "Firefox on Windows", isActive: active,
deviceType: .web, isCurrent: false),
isVerified: true, UserSessionInfo(id: "2 verified: \(verified) active: \(active)",
lastSeenIP: "2.0.0.2", name: "Firefox on Windows verified: \(verified) active: \(active)",
lastSeenTimestamp: Date().timeIntervalSince1970 - 100, deviceType: .web,
applicationName: "Element Web", isVerified: verified,
applicationVersion: "1.0.0", lastSeenIP: "2.0.0.2",
applicationURL: nil, lastSeenTimestamp: Date().timeIntervalSince1970 - 100,
deviceModel: nil, applicationName: "Element Web",
deviceOS: "Windows 10", applicationVersion: "1.0.0",
lastSeenIPLocation: nil, applicationURL: nil,
deviceName: "My Windows", deviceModel: nil,
isActive: true, deviceOS: "Windows 10",
isCurrent: false), lastSeenIPLocation: nil,
UserSessionInfo(id: "3", deviceName: "My Windows",
name: "Android", isActive: active,
deviceType: .mobile, isCurrent: false),
isVerified: false, UserSessionInfo(id: "3 verified: \(verified) active: \(active)",
lastSeenIP: "3.0.0.3", name: "Android verified: \(verified) active: \(active)",
lastSeenTimestamp: Date().timeIntervalSince1970 - 10, deviceType: .mobile,
applicationName: "Element Android", isVerified: verified,
applicationVersion: "1.0.0", lastSeenIP: "3.0.0.3",
applicationURL: nil, lastSeenTimestamp: Date().timeIntervalSince1970 - 10,
deviceModel: nil, applicationName: "Element Android",
deviceOS: "Android 4.0", applicationVersion: "1.0.0",
lastSeenIPLocation: nil, applicationURL: nil,
deviceName: "My Phone", deviceModel: nil,
isActive: true, deviceOS: "Android 4.0",
isCurrent: false) lastSeenIPLocation: nil,
] deviceName: "My Phone",
isActive: active,
isCurrent: false)]
}
} }

View file

@ -18,5 +18,38 @@ import RiotSwiftUI
import XCTest import XCTest
class UserSessionsOverviewUITests: MockScreenTestCase { class UserSessionsOverviewUITests: MockScreenTestCase {
// TODO: func testCurrentSessionUnverified() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.currentSessionUnverified.title)
XCTAssertTrue(app.buttons["userSessionCardVerifyButton"].exists)
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
}
func testCurrentSessionVerified() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.currentSessionVerified.title)
XCTAssertFalse(app.buttons["userSessionCardVerifyButton"].exists)
XCTAssertTrue(app.staticTexts["userSessionCardViewDetails"].exists)
}
func testOnlyUnverifiedSessions() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.onlyUnverifiedSessions.title)
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
}
func testOnlyInactiveSessions() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.onlyInactiveSessions.title)
XCTAssertTrue(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertTrue(app.staticTexts["userSessionsOverviewOtherSection"].exists)
}
func testNoOtherSessions() {
app.goToScreenWithIdentifier(MockUserSessionsOverviewScreenState.noOtherSessions.title)
XCTAssertFalse(app.staticTexts["userSessionsOverviewSecurityRecommendationsSection"].exists)
XCTAssertFalse(app.staticTexts["userSessionsOverviewOtherSection"].exists)
}
} }

View file

@ -20,13 +20,71 @@ import XCTest
@testable import RiotSwiftUI @testable import RiotSwiftUI
class UserSessionsOverviewViewModelTests: XCTestCase { class UserSessionsOverviewViewModelTests: XCTestCase {
var service: MockUserSessionsOverviewService! func testInitialStateEmpty() {
var viewModel: UserSessionsOverviewViewModelProtocol! let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: MockUserSessionsOverviewService())
var context: UserSessionsOverviewViewModelType.Context!
XCTAssertNil(viewModel.state.currentSessionViewData)
XCTAssertTrue(viewModel.state.unverifiedSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.inactiveSessionsViewData.isEmpty)
XCTAssertTrue(viewModel.state.otherSessionsViewData.isEmpty)
}
override func setUpWithError() throws { func testLoadOnDidAppear() {
service = MockUserSessionsOverviewService() let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: MockUserSessionsOverviewService())
viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service) viewModel.process(viewAction: .viewAppeared)
context = viewModel.context
XCTAssertNotNil(viewModel.state.currentSessionViewData)
XCTAssertFalse(viewModel.state.unverifiedSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.inactiveSessionsViewData.isEmpty)
XCTAssertFalse(viewModel.state.otherSessionsViewData.isEmpty)
}
func testSimpleActionProcessing() {
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: MockUserSessionsOverviewService())
var result: UserSessionsOverviewViewModelResult?
viewModel.completion = { action in
result = action
}
viewModel.process(viewAction: .verifyCurrentSession)
XCTAssertEqual(result, .verifyCurrentSession)
viewModel.process(viewAction: .viewAllUnverifiedSessions)
XCTAssertEqual(result, .showAllUnverifiedSessions)
viewModel.process(viewAction: .viewAllInactiveSessions)
XCTAssertEqual(result, .showAllInactiveSessions)
viewModel.process(viewAction: .viewAllOtherSessions)
XCTAssertEqual(result, .showAllOtherSessions)
}
func testShowSessionDetails() {
let service = MockUserSessionsOverviewService()
service.updateOverviewData { _ in }
let viewModel = UserSessionsOverviewViewModel(userSessionsOverviewService: service)
var result: UserSessionsOverviewViewModelResult?
viewModel.completion = { action in
result = action
}
guard let currentSession = service.overviewData.currentSession else {
XCTFail("The current session should be valid at this point")
return
}
viewModel.process(viewAction: .viewCurrentSessionDetails)
XCTAssertEqual(result, .showCurrentSessionOverview(session: currentSession))
guard let randomSession = service.overviewData.otherSessions.randomElement() else {
XCTFail("There should be other sessions")
return
}
viewModel.process(viewAction: .tapUserSession(randomSession.id))
XCTAssertEqual(result, .showUserSessionOverview(session: randomSession))
} }
} }

View file

@ -24,7 +24,7 @@ enum UserSessionsOverviewCoordinatorResult {
// MARK: View model // MARK: View model
enum UserSessionsOverviewViewModelResult { enum UserSessionsOverviewViewModelResult: Equatable {
case showAllUnverifiedSessions case showAllUnverifiedSessions
case showAllInactiveSessions case showAllInactiveSessions
case verifyCurrentSession case verifyCurrentSession

View file

@ -18,9 +18,6 @@ import Foundation
/// View data for UserSessionListItem /// View data for UserSessionListItem
struct UserSessionListItemViewData: Identifiable { struct UserSessionListItemViewData: Identifiable {
private static let userSessionNameFormatter = UserSessionNameFormatter()
private static let lastActivityDateFormatter = UserSessionLastActivityFormatter()
var id: String { var id: String {
sessionId sessionId
} }
@ -39,7 +36,7 @@ struct UserSessionListItemViewData: Identifiable {
isVerified: Bool, isVerified: Bool,
lastActivityDate: TimeInterval?) { lastActivityDate: TimeInterval?) {
self.sessionId = sessionId self.sessionId = sessionId
sessionName = Self.userSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName) sessionName = UserSessionNameFormatter.sessionName(deviceType: deviceType, sessionDisplayName: sessionDisplayName)
sessionDetails = Self.buildSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate) sessionDetails = Self.buildSessionDetails(isVerified: isVerified, lastActivityDate: lastActivityDate)
deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, isVerified: isVerified) deviceAvatarViewData = DeviceAvatarViewData(deviceType: deviceType, isVerified: isVerified)
} }
@ -54,7 +51,7 @@ struct UserSessionListItemViewData: Identifiable {
var lastActivityDateString: String? var lastActivityDateString: String?
if let lastActivityDate = lastActivityDate { if let lastActivityDate = lastActivityDate {
lastActivityDateString = Self.lastActivityDateFormatter.lastActivityDateString(from: lastActivityDate) lastActivityDateString = UserSessionLastActivityFormatter.lastActivityDateString(from: lastActivityDate)
} }
if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false { if let lastActivityDateString = lastActivityDateString, lastActivityDateString.isEmpty == false {

View file

@ -23,11 +23,13 @@ struct UserSessionsOverview: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
securityRecommendationsSection if hasSecurityRecommendations {
securityRecommendationsSection
}
currentSessionsSection currentSessionsSection
if viewModel.viewState.otherSessionsViewData.isEmpty == false { if !viewModel.viewState.otherSessionsViewData.isEmpty {
otherSessionsSection otherSessionsSection
} }
} }
@ -40,41 +42,39 @@ struct UserSessionsOverview: View {
} }
} }
@ViewBuilder
private var securityRecommendationsSection: some View { private var securityRecommendationsSection: some View {
if hasSecurityRecommendations { SwiftUI.Section {
SwiftUI.Section { if !viewModel.viewState.unverifiedSessionsViewData.isEmpty {
if !viewModel.viewState.unverifiedSessionsViewData.isEmpty { SecurityRecommendationCard(style: .unverified,
SecurityRecommendationCard(style: .unverified, sessionCount: viewModel.viewState.unverifiedSessionsViewData.count) {
sessionCount: viewModel.viewState.unverifiedSessionsViewData.count) { viewModel.send(viewAction: .viewAllUnverifiedSessions)
viewModel.send(viewAction: .viewAllUnverifiedSessions)
}
} }
if !viewModel.viewState.inactiveSessionsViewData.isEmpty {
SecurityRecommendationCard(style: .inactive,
sessionCount: viewModel.viewState.inactiveSessionsViewData.count) {
viewModel.send(viewAction: .viewAllInactiveSessions)
}
}
} header: {
VStack(alignment: .leading) {
Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionTitle)
.textCase(.uppercase)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 8.0)
Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionInfo)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 12.0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 24)
} }
.padding(.horizontal, 16)
if !viewModel.viewState.inactiveSessionsViewData.isEmpty {
SecurityRecommendationCard(style: .inactive,
sessionCount: viewModel.viewState.inactiveSessionsViewData.count) {
viewModel.send(viewAction: .viewAllInactiveSessions)
}
}
} header: {
VStack(alignment: .leading) {
Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionTitle)
.textCase(.uppercase)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 8.0)
Text(VectorL10n.userSessionsOverviewSecurityRecommendationsSectionInfo)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
.padding(.bottom, 12.0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 24)
} }
.padding(.horizontal, 16)
.accessibilityIdentifier("userSessionsOverviewSecurityRecommendationsSection")
} }
var hasSecurityRecommendations: Bool { var hasSecurityRecommendations: Bool {
@ -102,10 +102,9 @@ struct UserSessionsOverview: View {
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
} }
private var otherSessionsSection: some View { private var otherSessionsSection: some View {
SwiftUI.Section { SwiftUI.Section {
// Device list
LazyVStack(spacing: 0) { LazyVStack(spacing: 0) {
ForEach(viewModel.viewState.otherSessionsViewData) { viewData in ForEach(viewModel.viewState.otherSessionsViewData) { viewData in
UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in UserSessionListItem(viewData: viewData, onBackgroundTap: { sessionId in
@ -131,6 +130,7 @@ struct UserSessionsOverview: View {
.padding(.horizontal, 16.0) .padding(.horizontal, 16.0)
.padding(.top, 24.0) .padding(.top, 24.0)
} }
.accessibilityIdentifier("userSessionsOverviewOtherSection")
} }
} }

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotSwiftUnitTests - RiotSwiftUnitTests
gatherCoverageData: true
coverageTargets:
- RiotSwiftUI
targets: targets:
RiotSwiftUI: RiotSwiftUI:

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotSwiftUITests - RiotSwiftUITests
gatherCoverageData: true
coverageTargets:
- RiotSwiftUI
targets: targets:
RiotSwiftUITests: RiotSwiftUITests:

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotSwiftUnitTests - RiotSwiftUnitTests
gatherCoverageData: true
coverageTargets:
- RiotSwiftUI
targets: targets:
RiotSwiftUnitTests: RiotSwiftUnitTests:

View file

@ -0,0 +1,267 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import Element
@testable import MatrixSDK
private let currentDeviceId = "deviceId"
private let unverifiedDeviceId = "unverifiedDeviceId"
private let currentUserId = "userId"
class UserSessionsOverviewServiceTests: XCTestCase {
func testInitialSessionUnverified() {
let dataProvider = MockUserSessionsDataProvider(mode: .currentSessionUnverified)
let service = UserSessionsOverviewService(dataProvider: dataProvider)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertFalse(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty)
XCTAssertEqual(service.sessionForIdentifier(currentDeviceId), service.overviewData.currentSession)
}
func testInitialSessionVerified() {
let dataProvider = MockUserSessionsDataProvider(mode: .currentSessionVerified)
let service = UserSessionsOverviewService(dataProvider: dataProvider)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty)
}
func testWithAllSessionsVerified() {
let service = setupServiceWithMode(.allOtherSessionsValid)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty)
XCTAssertFalse(service.overviewData.otherSessions.isEmpty)
}
func testWithSomeUnverifiedSessions() {
let service = setupServiceWithMode(.someUnverifiedSessions)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertTrue(service.overviewData.inactiveSessions.isEmpty)
XCTAssertFalse(service.overviewData.otherSessions.isEmpty)
}
func testWithSomeInactiveSessions() {
let service = setupServiceWithMode(.someInactiveSessions)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertTrue(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty)
XCTAssertFalse(service.overviewData.otherSessions.isEmpty)
}
func testWithSomeUnverifiedAndInactiveSessions() {
let service = setupServiceWithMode(.someUnverifiedAndInactiveSessions)
XCTAssertNotNil(service.overviewData.currentSession)
XCTAssertTrue(service.overviewData.currentSession?.isVerified ?? false)
XCTAssertTrue(service.overviewData.currentSession?.isActive ?? false)
XCTAssertFalse(service.overviewData.unverifiedSessions.isEmpty)
XCTAssertFalse(service.overviewData.inactiveSessions.isEmpty)
XCTAssertFalse(service.overviewData.otherSessions.isEmpty)
}
// MARK: - Private
private func setupServiceWithMode(_ mode: MockUserSessionsDataProvider.Mode) -> UserSessionsOverviewServiceProtocol {
let dataProvider = MockUserSessionsDataProvider(mode: mode)
let service = UserSessionsOverviewService(dataProvider: dataProvider)
let expectation = expectation(description: "Wait for service update")
service.updateOverviewData { _ in
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
return service
}
}
private class MockUserSessionsDataProvider: UserSessionsDataProviderProtocol {
enum Mode {
case currentSessionUnverified
case currentSessionVerified
case allOtherSessionsValid
case someUnverifiedSessions
case someInactiveSessions
case someUnverifiedAndInactiveSessions
}
private let mode: Mode
var myDeviceId = currentDeviceId
var myUserId: String? = currentUserId
var activeAccounts: [MXKAccount] {
[MockAccount()]
}
init(mode: Mode) {
self.mode = mode
}
func devices(completion: @escaping (MXResponse<[MXDevice]>) -> Void) {
DispatchQueue.main.async {
switch self.mode {
case .currentSessionUnverified:
return
case .currentSessionVerified:
return
case .allOtherSessionsValid:
completion(.success(self.verifiedSessions))
case .someUnverifiedSessions:
completion(.success(self.verifiedSessions + self.unverifiedSessions))
case .someInactiveSessions:
completion(.success(self.verifiedSessions + self.inactiveSessions))
case .someUnverifiedAndInactiveSessions:
completion(.success(self.verifiedSessions + self.unverifiedSessions + self.inactiveSessions))
}
}
}
func device(withDeviceId deviceId: String, ofUser userId: String) -> MXDeviceInfo {
guard deviceId == currentDeviceId else {
return MockDeviceInfo(verified: deviceId != unverifiedDeviceId)
}
switch mode {
case .currentSessionUnverified:
return MockDeviceInfo(verified: false)
default:
return MockDeviceInfo(verified: true)
}
}
func accountData(for eventType: String) -> [AnyHashable : Any]? {
[:]
}
// MARK: - Private
var verifiedSessions: [MXDevice] {
[MockDevice(identifier: currentDeviceId, sessionActive: true),
MockDevice(identifier: UUID().uuidString, sessionActive: true)]
}
var unverifiedSessions: [MXDevice] {
[MockDevice(identifier: unverifiedDeviceId, sessionActive: true)]
}
var inactiveSessions: [MXDevice] {
[MockDevice(identifier: UUID().uuidString, sessionActive: false)]
}
}
private class MockAccount: MXKAccount {
override var device: MXDevice? {
MockDevice(identifier: currentDeviceId, sessionActive: true)
}
}
private class MockDevice: MXDevice {
private let identifier: String
private let sessionActive: Bool
init(identifier: String, sessionActive: Bool) {
self.identifier = identifier
self.sessionActive = sessionActive
super.init()
}
required init?(coder: NSCoder) {
fatalError()
}
override var deviceId: String {
get {
identifier
}
set {
}
}
override var lastSeenTs: UInt64 {
get {
if sessionActive {
return UInt64(Date().timeIntervalSince1970 * 1000)
} else {
let ninetyDays: Double = 90 * 86400
return UInt64((Date().timeIntervalSince1970 - ninetyDays) * 1000)
}
}
set {
}
}
}
private class MockDeviceInfo: MXDeviceInfo {
private let verified: Bool
init(verified: Bool) {
self.verified = verified
super.init()
}
required init?(coder: NSCoder) {
fatalError()
}
override var trustLevel: MXDeviceTrustLevel! {
MockDeviceTrustLevel(verified: verified)
}
}
private class MockDeviceTrustLevel: MXDeviceTrustLevel {
private let verified: Bool
init(verified: Bool) {
self.verified = verified
super.init()
}
required init?(coder: NSCoder) {
fatalError()
}
override var isVerified: Bool {
verified
}
}

View file

@ -24,6 +24,9 @@ schemes:
disableMainThreadChecker: true disableMainThreadChecker: true
targets: targets:
- RiotTests - RiotTests
gatherCoverageData: true
coverageTargets:
- Riot
targets: targets:
RiotTests: RiotTests: