Files
Matt Rubin e095891450 Prefer XCTAssertEqual and XCTAssertNotEqual over XCTAssert
When tests fail, the more specific matchers provide better error messages.
2026-01-06 13:19:27 -05:00

293 lines
11 KiB
Swift

//
// RootTests.swift
// Authenticator
//
// Copyright (c) 2017-2026 Authenticator authors
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
//
import XCTest
@testable import Authenticator
class RootTests: XCTestCase {
private struct FakeError: Error {}
private let defaultDigitGroupSize = 2
let displayTime = DisplayTime(date: Date())
func testShowBackupInfo() {
var root = Root(deviceCanScan: false)
// Ensure there is no modal visible.
let (firstViewModel, _) = root.viewModel(with: [], at: displayTime, digitGroupSize: defaultDigitGroupSize)
guard case .none = firstViewModel.modal else {
XCTFail("Expected .none, got \(firstViewModel.modal)")
return
}
// Show the backup info.
let showAction: Root.Action = .tokenListAction(.showBackupInfo)
let showEffect: Root.Effect?
do {
showEffect = try root.update(with: showAction)
} catch {
XCTFail("Unexpected error: \(error)")
return
}
XCTAssertNil(showEffect)
// Ensure the backup info modal is visible.
let (secondViewModel, _) = root.viewModel(with: [], at: displayTime, digitGroupSize: defaultDigitGroupSize)
switch secondViewModel.modal {
case .menu(let menu):
switch menu.child {
case .info(let infoViewModel):
XCTAssertEqual(infoViewModel.title, "Backups")
default:
XCTFail("Expected Backups .info, got \(menu.child)")
}
default:
XCTFail("Expected .menu, got \(secondViewModel.modal)")
}
// Hide the backup info.
let hideAction: Root.Action = .menuAction(.infoEffect(.done))
let hideEffect: Root.Effect?
do {
hideEffect = try root.update(with: hideAction)
} catch {
XCTFail("Unexpected error: \(error)")
return
}
XCTAssertNil(hideEffect)
// Ensure the backup info modal no longer visible.
let (thirdViewModel, _) = root.viewModel(with: [], at: displayTime, digitGroupSize: defaultDigitGroupSize)
guard case .none = thirdViewModel.modal else {
XCTFail("Expected .none, got \(thirdViewModel.modal)")
return
}
}
func testShowLicenseInfo() {
var root = Root(deviceCanScan: false)
// Ensure there is no modal visible.
let (firstViewModel, _) = root.viewModel(with: [], at: displayTime, digitGroupSize: defaultDigitGroupSize)
guard case .none = firstViewModel.modal else {
XCTFail("Expected .none, got \(firstViewModel.modal)")
return
}
// Show the info list.
let showInfoAction: Root.Action = .tokenListAction(.showInfo)
let showInfoEffect: Root.Effect?
do {
showInfoEffect = try root.update(with: showInfoAction)
} catch {
XCTFail("Unexpected error: \(error)")
return
}
XCTAssertNil(showInfoEffect)
// Ensure the info list modal is visible.
let (nextViewModel, _) = root.viewModel(with: [], at: displayTime, digitGroupSize: defaultDigitGroupSize)
guard case .menu(let menu) = nextViewModel.modal, case .none = menu.child else {
XCTFail("Expected .info list, got \(nextViewModel.modal)")
return
}
// Show the license info.
let showAction: Root.Action = .menuAction(.infoListEffect(.showLicenseInfo))
let showEffect: Root.Effect?
do {
showEffect = try root.update(with: showAction)
} catch {
XCTFail("Unexpected error: \(error)")
return
}
XCTAssertNil(showEffect)
// Ensure the license info modal is visible.
let (secondViewModel, _) = root.viewModel(with: [], at: displayTime, digitGroupSize: defaultDigitGroupSize)
switch secondViewModel.modal {
case .menu(let menu):
switch menu.child {
case .info(let infoViewModel):
XCTAssertEqual(infoViewModel.title, "Acknowledgements")
default:
XCTFail("Expected Acknowledgements .info, got \(menu.child)")
}
default:
XCTFail("Expected .menu, got \(secondViewModel.modal)")
}
// Hide the license info.
let hideAction: Root.Action = .menuAction(.infoEffect(.done))
let hideEffect: Root.Effect?
do {
hideEffect = try root.update(with: hideAction)
} catch {
XCTFail("Unexpected error: \(error)")
return
}
XCTAssertNil(hideEffect)
// Ensure the license info modal no longer visible.
let (thirdViewModel, _) = root.viewModel(with: [], at: displayTime, digitGroupSize: defaultDigitGroupSize)
guard case .none = thirdViewModel.modal else {
XCTFail("Expected .none, got \(thirdViewModel.modal)")
return
}
}
func testOpenURL() {
var root = Root(deviceCanScan: false)
guard let url = URL(string: "https://example.com") else {
XCTFail("Failed to initialize URL.")
return
}
let action: Root.Action = .menuAction(.infoEffect(.openURL(url)))
let effect: Root.Effect?
do {
XCTAssertNil(try root.update(with: .tokenListAction(.showBackupInfo)))
effect = try root.update(with: action)
} catch {
XCTFail("Unexpected error: \(error)")
return
}
guard case .some(.openURL(url)) = effect else {
XCTFail("Expected .openURL(\(url)), got \(String(describing: effect))")
return
}
}
func testEventAddTokenFailed() {
var root = Root(deviceCanScan: false)
let event: Root.Event = .addTokenFailed(FakeError())
let effect = root.update(with: event)
// TODO: check that the component state hasn't changed
guard case .some(.showErrorMessage("Failed to add token.")) = effect else {
XCTFail("Expected .showErrorMessage(\"Failed to add token.\"), got \(String(describing: effect))")
return
}
}
// MARK: Events
func testEventAddTokenFromURLSucceeded() {
var root = Root(deviceCanScan: false)
let event: Root.Event = .addTokenFromURLSucceeded
let effect = root.update(with: event)
// TODO: check that the component state hasn't changed
XCTAssertNil(effect)
}
func testEventTokenFormSucceeded() {
var root = Root(deviceCanScan: false)
func modalViewModel(from root: Root) -> RootViewModel.ModalViewModel {
return root.viewModel(with: [], at: displayTime, digitGroupSize: defaultDigitGroupSize).viewModel.modal
}
// Ensure the initial view model has no modal.
guard case .none = modalViewModel(from: root) else {
XCTFail("The initial view model should have no modal.")
return
}
// Show the token entry form.
do {
let effect = try root.update(with: .tokenListAction(.beginAddToken))
XCTAssertNil(effect)
} catch {
XCTFail("Caught unexpected error: \(error)")
}
// Ensure the view model now has a modal entry form.
guard case .entryForm = modalViewModel(from: root) else {
XCTFail("The view model should have a modal entry form.")
return
}
// Signal token entry success.
let effect = root.update(with: .tokenFormSucceeded)
XCTAssertNil(effect)
// Ensure the token entry form hides on success.
guard case .none = modalViewModel(from: root) else {
XCTFail("The final view model should have no modal.")
return
}
}
func testEventSaveTokenFailed() {
var root = Root(deviceCanScan: false)
let event: Root.Event = .saveTokenFailed(FakeError())
let effect = root.update(with: event)
// TODO: check that the component state hasn't changed
guard case .some(.showErrorMessage("Failed to save token.")) = effect else {
XCTFail("Expected .showErrorMessage(\"Failed to save token.\"), got \(String(describing: effect))")
return
}
}
func testEventUpdateTokenFailed() {
var root = Root(deviceCanScan: false)
let event: Root.Event = .updateTokenFailed(FakeError())
let effect = root.update(with: event)
// TODO: check that the component state hasn't changed
guard case .some(.showErrorMessage("Failed to update token.")) = effect else {
XCTFail("Expected .showErrorMessage(\"Failed to update token.\"), got \(String(describing: effect))")
return
}
}
func testEventMoveTokenFailed() {
var root = Root(deviceCanScan: false)
let event: Root.Event = .moveTokenFailed(FakeError())
let effect = root.update(with: event)
// TODO: check that the component state hasn't changed
guard case .some(.showErrorMessage("Failed to move token.")) = effect else {
XCTFail("Expected .showErrorMessage(\"Failed to move token.\"), got \(String(describing: effect))")
return
}
}
func testEventDeleteTokenFailed() {
var root = Root(deviceCanScan: false)
let event: Root.Event = .deleteTokenFailed(FakeError())
let effect = root.update(with: event)
// TODO: check that the component state hasn't changed
guard case .some(.showErrorMessage("Failed to delete token.")) = effect else {
XCTFail("Expected .showErrorMessage(\"Failed to delete token.\"), got \(String(describing: effect))")
return
}
}
}