Add tests for linking a new device. (#4934)

* Replace GrantLoginWithQrCodeHandlerSDKMock with LinkNewDeviceServiceMock.

Add tests for all initial states on the QRCodeLoginScreen.

* Add tests for linking both mobile and desktop devices.

* Add UI tests for linking a new device.

* Don't show the Link Desktop Computer button when running on macOS.

This mirrors the decision to hide the Sign In With QR Code button on the start screen.
This commit is contained in:
Doug
2026-01-09 13:10:14 +00:00
committed by GitHub
parent 00dc901ce1
commit 7cb472a4f7
21 changed files with 409 additions and 93 deletions
@@ -9,41 +9,63 @@
import Combine
import XCTest
import MatrixRustSDK
@testable import ElementX
import MatrixRustSDKMocks
@MainActor
final class QRCodeLoginScreenViewModelTests: XCTestCase {
private var qrProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
private var qrServiceMock: QRCodeLoginServiceMock!
private var appMediatorMock: AppMediatorMock!
private var qrLoginProgressSubject: CurrentValueSubject<QRLoginProgress, AuthenticationServiceError>!
private var qrCodeLoginService: QRCodeLoginServiceMock!
private var linkMobileProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkMobileProgress, QRCodeLoginError>!
private var linkDesktopProgressSubject: CurrentValueSubject<LinkNewDeviceService.LinkDesktopProgress, QRCodeLoginError>!
private var linkNewDeviceService: LinkNewDeviceServiceMock!
private var appMediator: AppMediatorMock!
private var viewModel: QRCodeLoginScreenViewModelProtocol!
private var context: QRCodeLoginScreenViewModelType.Context {
viewModel.context
}
private var context: QRCodeLoginScreenViewModelType.Context { viewModel.context }
override func setUp() {
qrProgressSubject = .init(.starting)
qrServiceMock = QRCodeLoginServiceMock()
qrServiceMock.loginWithQRCodeDataReturnValue = qrProgressSubject.asCurrentValuePublisher()
appMediatorMock = AppMediatorMock.default
viewModel = QRCodeLoginScreenViewModel(mode: .login(qrServiceMock),
canSignInManually: true,
appMediator: appMediatorMock)
}
func testInitialState() {
func testLoginInitialState() {
setupViewModel(mode: .login)
XCTAssertEqual(context.viewState.state, .loginInstructions)
XCTAssertNil(context.qrResult)
XCTAssertFalse(qrServiceMock.loginWithQRCodeDataCalled)
XCTAssertFalse(appMediatorMock.requestAuthorizationIfNeededCalled)
XCTAssertFalse(appMediatorMock.openAppSettingsCalled)
XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled)
XCTAssertFalse(appMediator.requestAuthorizationIfNeededCalled)
XCTAssertFalse(appMediator.openAppSettingsCalled)
XCTAssertFalse(linkNewDeviceService.linkMobileDeviceCalled)
XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled)
}
func testLinkDesktopInitialState() {
setupViewModel(mode: .linkDesktop)
XCTAssertEqual(context.viewState.state, .linkDesktopInstructions)
XCTAssertNil(context.qrResult)
XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled)
XCTAssertFalse(appMediator.requestAuthorizationIfNeededCalled)
XCTAssertFalse(appMediator.openAppSettingsCalled)
XCTAssertFalse(linkNewDeviceService.linkMobileDeviceCalled)
XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled)
}
func testLinkMobileInitialState() {
setupViewModel(mode: .linkMobile)
XCTAssertTrue(context.viewState.state.isDisplayQR)
XCTAssertTrue(linkNewDeviceService.linkMobileDeviceCalled)
XCTAssertFalse(linkNewDeviceService.linkDesktopDeviceWithCalled)
XCTAssertFalse(qrCodeLoginService.loginWithQRCodeDataCalled)
XCTAssertNil(context.qrResult)
}
func testRequestCameraPermission() async throws {
appMediatorMock.requestAuthorizationIfNeededReturnValue = false
setupViewModel(mode: .login)
appMediator.requestAuthorizationIfNeededReturnValue = false
XCTAssert(context.viewState.state == .loginInstructions)
let deferred = deferFulfillment(viewModel.context.$viewState) { state in
@@ -51,15 +73,16 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
}
context.send(viewAction: .startScan)
try await deferred.fulfill()
XCTAssertTrue(appMediatorMock.requestAuthorizationIfNeededCalled)
XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled)
context.send(viewAction: .errorAction(.openSettings))
await Task.yield()
XCTAssertTrue(appMediatorMock.openAppSettingsCalled)
XCTAssertTrue(appMediator.openAppSettingsCalled)
XCTAssertNil(context.qrResult)
}
func testLogin() async throws {
setupViewModel(mode: .login)
XCTAssert(context.viewState.state == .loginInstructions)
var deferred = deferFulfillment(context.$viewState) { state in
@@ -67,7 +90,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
}
context.send(viewAction: .startScan)
try await deferred.fulfill()
XCTAssertTrue(appMediatorMock.requestAuthorizationIfNeededCalled)
XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled)
deferred = deferFulfillment(context.$viewState) { state in
state.state == .scan(.connecting)
@@ -78,13 +101,13 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
deferred = deferFulfillment(context.$viewState) { state in
state.state == .displayCode(.deviceCode("01"))
}
qrProgressSubject.send(.establishingSecureChannel(checkCode: 1, checkCodeString: "01"))
qrLoginProgressSubject.send(.establishingSecureChannel(checkCode: 1, checkCodeString: "01"))
try await deferred.fulfill()
deferred = deferFulfillment(context.$viewState) { state in
state.state == .displayCode(.verificationCode("ABCDEF"))
}
qrProgressSubject.send(.waitingForToken(userCode: "ABCDEF"))
qrLoginProgressSubject.send(.waitingForToken(userCode: "ABCDEF"))
try await deferred.fulfill()
let deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
@@ -93,7 +116,109 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
default: false
}
}
qrProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock()))))
qrLoginProgressSubject.send(.signedIn(UserSessionMock(.init(clientProxy: ClientProxyMock()))))
try await deferredAction.fulfill()
}
func testLinkDesktopComputer() async throws {
setupViewModel(mode: .linkDesktop)
XCTAssert(context.viewState.state == .linkDesktopInstructions)
var deferred = deferFulfillment(context.$viewState) { $0.state == .scan(.scanning) }
context.send(viewAction: .startScan)
try await deferred.fulfill()
XCTAssertTrue(appMediator.requestAuthorizationIfNeededCalled)
deferred = deferFulfillment(context.$viewState) { $0.state == .scan(.connecting) }
context.qrResult = .init()
try await deferred.fulfill()
deferred = deferFulfillment(context.$viewState) { $0.state == .displayCode(.deviceCode("01")) }
linkDesktopProgressSubject.send(.establishingSecureChannel(checkCodeString: "01"))
try await deferred.fulfill()
var deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .requestOIDCAuthorisation = action else { return false }
return true
}
linkDesktopProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory))
try await deferredAction.fulfill()
let currentState = context.viewState.state
let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState }
linkDesktopProgressSubject.send(.syncingSecrets)
try await deferredFailure.fulfill()
deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .dismiss = action else { return false }
return true
}
linkDesktopProgressSubject.send(.done)
try await deferredAction.fulfill()
}
func testLinkMobileDevice() async throws {
setupViewModel(mode: .linkMobile)
XCTAssert(context.viewState.state.isDisplayQR)
let checkCodeSender = CheckCodeSenderSDKMock()
let checkCodeSenderProxy = CheckCodeSenderProxy(underlyingSender: checkCodeSender)
var deferredState = deferFulfillment(context.$viewState) { $0.state == .confirmCode(.inputCode(checkCodeSenderProxy)) }
linkMobileProgressSubject.send(.qrScanned(checkCodeSenderProxy))
try await deferredState.fulfill()
deferredState = deferFulfillment(context.$viewState) { $0.state == .confirmCode(.sendingCode) }
context.checkCodeInput = "01"
context.send(viewAction: .sendCheckCode)
try await deferredState.fulfill()
var deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .requestOIDCAuthorisation = action else { return false }
return true
}
linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory))
try await deferredAction.fulfill()
let currentState = context.viewState.state
let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState }
linkMobileProgressSubject.send(.syncingSecrets)
try await deferredFailure.fulfill()
deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .dismiss = action else { return false }
return true
}
linkMobileProgressSubject.send(.done)
try await deferredAction.fulfill()
}
// MARK: - Helpers
enum Mode { case login, linkDesktop, linkMobile }
private func setupViewModel(mode: Mode) {
qrLoginProgressSubject = .init(.starting)
qrCodeLoginService = QRCodeLoginServiceMock()
qrCodeLoginService.loginWithQRCodeDataReturnValue = qrLoginProgressSubject.asCurrentValuePublisher()
linkMobileProgressSubject = .init(.qrReady(LinkNewDeviceServiceMock.mockQRCodeImage))
linkDesktopProgressSubject = .init(.starting)
linkNewDeviceService = LinkNewDeviceServiceMock(.init(linkMobileProgressPublisher: linkMobileProgressSubject.asCurrentValuePublisher(),
linkDesktopProgressPublisher: linkDesktopProgressSubject.asCurrentValuePublisher()))
let screenMode: QRCodeLoginScreenMode
switch mode {
case .login:
screenMode = .login(qrCodeLoginService)
case .linkDesktop:
screenMode = .linkDesktop(linkNewDeviceService)
case .linkMobile:
screenMode = .linkMobile(linkNewDeviceService.linkMobileDevice())
}
appMediator = AppMediatorMock.default
viewModel = QRCodeLoginScreenViewModel(mode: screenMode,
canSignInManually: true,
appMediator: appMediator)
}
}