Swift Testing for Unit Tests PART 1 (#5119)
* migrated a lot of unit tests to Swift Testing and added a new implementation for deferred fulfillment more tests migration Cleaned the code manually to establish some good patterns more code improvements some more code improvements removed empty tests update project * more pr suggestions and cleanups * removed the TestSetup pattern * fixing claude not reusing tests * pr suggestion + added indent rule to swiftformat so that we can prevent AIs to change that
This commit is contained in:
@@ -7,20 +7,21 @@
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import XCTest
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
class AppLockScreenViewModelTests: XCTestCase {
|
||||
var appSettings: AppSettings!
|
||||
var appLockService: AppLockService!
|
||||
var keychainController: KeychainControllerMock!
|
||||
var viewModel: AppLockScreenViewModelProtocol!
|
||||
@Suite
|
||||
final class AppLockScreenViewModelTests {
|
||||
var appSettings: AppSettings
|
||||
var appLockService: AppLockService
|
||||
var keychainController: KeychainControllerMock
|
||||
var viewModel: AppLockScreenViewModelProtocol
|
||||
|
||||
var context: AppLockScreenViewModelType.Context {
|
||||
viewModel.context
|
||||
}
|
||||
|
||||
override func setUp() {
|
||||
init() {
|
||||
AppSettings.resetAllSettings()
|
||||
appSettings = AppSettings()
|
||||
keychainController = KeychainControllerMock()
|
||||
@@ -28,11 +29,12 @@ class AppLockScreenViewModelTests: XCTestCase {
|
||||
viewModel = AppLockScreenViewModel(appLockService: appLockService)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
deinit {
|
||||
AppSettings.resetAllSettings()
|
||||
}
|
||||
|
||||
func testUnlock() async throws {
|
||||
@Test
|
||||
func unlock() async throws {
|
||||
// Given a valid PIN code.
|
||||
let pinCode = "2023"
|
||||
keychainController.pinCodeReturnValue = pinCode
|
||||
@@ -44,18 +46,19 @@ class AppLockScreenViewModelTests: XCTestCase {
|
||||
let result = try await deferred.fulfill()
|
||||
|
||||
// The app should become unlocked.
|
||||
XCTAssertEqual(result, .appUnlocked)
|
||||
#expect(result == .appUnlocked)
|
||||
}
|
||||
|
||||
func testForgotPIN() async throws {
|
||||
@Test
|
||||
func forgotPIN() async throws {
|
||||
// Given a fresh launch of the app.
|
||||
XCTAssertNil(context.alertInfo, "No alert should be shown initially.")
|
||||
#expect(context.alertInfo == nil, "No alert should be shown initially.")
|
||||
|
||||
// When the user has forgotten their PIN.
|
||||
context.send(viewAction: .forgotPIN)
|
||||
|
||||
// Then an alert should be shown before logging out.
|
||||
XCTAssertEqual(context.alertInfo?.id, .confirmResetPIN, "An alert should be shown before logging out.")
|
||||
#expect(context.alertInfo?.id == .confirmResetPIN, "An alert should be shown before logging out.")
|
||||
|
||||
// When confirming the logout.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .forceLogout }
|
||||
@@ -65,14 +68,15 @@ class AppLockScreenViewModelTests: XCTestCase {
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
|
||||
func testUnlockFailure() async throws {
|
||||
@Test
|
||||
func unlockFailure() async throws {
|
||||
// Given an invalid PIN code.
|
||||
let pinCode = "2024"
|
||||
keychainController.pinCodeReturnValue = "2023"
|
||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||
XCTAssertEqual(context.viewState.numberOfPINAttempts, 0, "The shouldn't be any attempts yet.")
|
||||
XCTAssertFalse(context.viewState.isSubtitleWarning, "No warning should be shown yet.")
|
||||
XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
|
||||
#expect(context.viewState.numberOfPINAttempts == 0, "The shouldn't be any attempts yet.")
|
||||
#expect(!context.viewState.isSubtitleWarning, "No warning should be shown yet.")
|
||||
#expect(context.alertInfo == nil, "No alert should be shown yet.")
|
||||
|
||||
// When entering it on the lock screen.
|
||||
var deferred = deferFulfillment(context.$viewState) { $0.numberOfPINAttempts == 1 }
|
||||
@@ -81,9 +85,9 @@ class AppLockScreenViewModelTests: XCTestCase {
|
||||
context.send(viewAction: .clearPINCode) // Simulate the animation completion
|
||||
|
||||
// Then a failed attempt should be shown.
|
||||
XCTAssertEqual(context.viewState.numberOfPINAttempts, 1, "A failed attempt should have been recorded.")
|
||||
XCTAssertTrue(context.viewState.isSubtitleWarning, "A warning should now be shown.")
|
||||
XCTAssertNil(context.alertInfo, "No alert should be shown yet.")
|
||||
#expect(context.viewState.numberOfPINAttempts == 1, "A failed attempt should have been recorded.")
|
||||
#expect(context.viewState.isSubtitleWarning, "A warning should now be shown.")
|
||||
#expect(context.alertInfo == nil, "No alert should be shown yet.")
|
||||
|
||||
// When entering twice more
|
||||
deferred = deferFulfillment(context.$viewState) { $0.numberOfPINAttempts == 2 }
|
||||
@@ -96,28 +100,28 @@ class AppLockScreenViewModelTests: XCTestCase {
|
||||
context.send(viewAction: .clearPINCode) // Simulate the animation completion
|
||||
|
||||
// Then an alert should be shown
|
||||
XCTAssertEqual(context.viewState.numberOfPINAttempts, 3, "All the attempts should have been recorded.")
|
||||
XCTAssertTrue(context.viewState.isSubtitleWarning, "The warning should still be shown.")
|
||||
XCTAssertEqual(context.alertInfo?.id, .forcedLogout, "An alert should now be shown.")
|
||||
#expect(context.viewState.numberOfPINAttempts == 3, "All the attempts should have been recorded.")
|
||||
#expect(context.viewState.isSubtitleWarning, "The warning should still be shown.")
|
||||
#expect(context.alertInfo?.id == .forcedLogout, "An alert should now be shown.")
|
||||
}
|
||||
|
||||
func testForceQuitRequiresLogout() async throws {
|
||||
@Test
|
||||
func forceQuitRequiresLogout() async throws {
|
||||
// Given an app with a PIN set where the user attempted to unlock 3 times.
|
||||
keychainController.pinCodeReturnValue = "2023"
|
||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||
appSettings.appLockNumberOfPINAttempts = 2
|
||||
XCTAssertNil(context.alertInfo)
|
||||
#expect(context.alertInfo == nil)
|
||||
let deferred = deferFulfillment(context.$viewState) { $0.numberOfPINAttempts == 3 }
|
||||
viewModel.context.pinCode = "0000"
|
||||
try await deferred.fulfill()
|
||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 3, "The app should have 3 failed attempts before the force quit.")
|
||||
XCTAssertEqual(context.alertInfo?.id, .forcedLogout, "The app should be showing the alert before the force quit.")
|
||||
#expect(appSettings.appLockNumberOfPINAttempts == 3, "The app should have 3 failed attempts before the force quit.")
|
||||
#expect(context.alertInfo?.id == .forcedLogout, "The app should be showing the alert before the force quit.")
|
||||
|
||||
// When force quitting the app and relaunching.
|
||||
viewModel = nil
|
||||
let freshViewModel = AppLockScreenViewModel(appLockService: appLockService)
|
||||
|
||||
// Then the alert should remain in place
|
||||
XCTAssertEqual(freshViewModel.context.alertInfo?.id, .forcedLogout, "The new view model from the fresh launch should also show the alert")
|
||||
#expect(freshViewModel.context.alertInfo?.id == .forcedLogout, "The new view model from the fresh launch should also show the alert")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,17 @@
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import XCTest
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
class AppLockServiceTests: XCTestCase {
|
||||
var keychainController: KeychainController!
|
||||
var appSettings: AppSettings!
|
||||
var service: AppLockService!
|
||||
@Suite
|
||||
final class AppLockServiceTests {
|
||||
private var keychainController: KeychainController
|
||||
private var appSettings: AppSettings
|
||||
private var service: AppLockService
|
||||
|
||||
override func setUp() {
|
||||
init() {
|
||||
AppSettings.resetAllSettings()
|
||||
appSettings = AppSettings()
|
||||
|
||||
@@ -26,34 +28,36 @@ class AppLockServiceTests: XCTestCase {
|
||||
service.disable()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
deinit {
|
||||
AppSettings.resetAllSettings()
|
||||
}
|
||||
|
||||
// MARK: - PIN Code
|
||||
|
||||
func testValidPINCode() {
|
||||
@Test
|
||||
func validPINCode() {
|
||||
// Given a service that hasn't been enabled.
|
||||
XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.")
|
||||
#expect(!service.isEnabled, "The service shouldn't be enabled to begin with.")
|
||||
|
||||
// When setting a PIN code.
|
||||
let pinCode = "2023" // Highly secure PIN that is rotated every 12 months.
|
||||
guard case .success = service.setupPINCode(pinCode) else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
|
||||
// Then service should be enabled and only the provided PIN should work to unlock the app.
|
||||
XCTAssertTrue(service.isEnabled, "The service should become enabled when setting a PIN.")
|
||||
XCTAssertTrue(service.unlock(with: pinCode), "The provided PIN code should work.")
|
||||
XCTAssertFalse(service.unlock(with: "2024"), "No other PIN code should work.")
|
||||
XCTAssertFalse(service.unlock(with: "1234"), "No other PIN code should work.")
|
||||
XCTAssertFalse(service.unlock(with: "9999"), "No other PIN code should work.")
|
||||
#expect(service.isEnabled, "The service should become enabled when setting a PIN.")
|
||||
#expect(service.unlock(with: pinCode), "The provided PIN code should work.")
|
||||
#expect(!service.unlock(with: "2024"), "No other PIN code should work.")
|
||||
#expect(!service.unlock(with: "1234"), "No other PIN code should work.")
|
||||
#expect(!service.unlock(with: "9999"), "No other PIN code should work.")
|
||||
}
|
||||
|
||||
func testWeakPINCode() {
|
||||
@Test
|
||||
func weakPINCode() {
|
||||
// Given a service that hasn't been enabled.
|
||||
XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.")
|
||||
#expect(!service.isEnabled, "The service shouldn't be enabled to begin with.")
|
||||
|
||||
// When setting a PIN code that is in the block list.
|
||||
let pinCode = appSettings.appLockPINCodeBlockList[0]
|
||||
@@ -61,16 +65,17 @@ class AppLockServiceTests: XCTestCase {
|
||||
|
||||
// Then the setup should fail and the service be left as disabled.
|
||||
guard case let .failure(error) = result else {
|
||||
XCTFail("The call should have failed.")
|
||||
Issue.record("The call should have failed.")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(error, .weakPIN, "The PIN should be rejected as weak.")
|
||||
XCTAssertFalse(service.isEnabled, "The service should remain disabled.")
|
||||
#expect(error == .weakPIN, "The PIN should be rejected as weak.")
|
||||
#expect(!service.isEnabled, "The service should remain disabled.")
|
||||
}
|
||||
|
||||
func testShortPINCode() {
|
||||
@Test
|
||||
func shortPINCode() {
|
||||
// Given a service that hasn't been enabled.
|
||||
XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.")
|
||||
#expect(!service.isEnabled, "The service shouldn't be enabled to begin with.")
|
||||
|
||||
// When setting a PIN code that is too short
|
||||
let pinCode = "123"
|
||||
@@ -78,16 +83,17 @@ class AppLockServiceTests: XCTestCase {
|
||||
|
||||
// Then the setup should fail and the service be left as disabled.
|
||||
guard case let .failure(error) = result else {
|
||||
XCTFail("The call should have failed.")
|
||||
Issue.record("The call should have failed.")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(error, .invalidPIN, "The PIN should be rejected as invalid.")
|
||||
XCTAssertFalse(service.isEnabled, "The service should remain disabled.")
|
||||
#expect(error == .invalidPIN, "The PIN should be rejected as invalid.")
|
||||
#expect(!service.isEnabled, "The service should remain disabled.")
|
||||
}
|
||||
|
||||
func testNonNumericPINCode() {
|
||||
@Test
|
||||
func nonNumericPINCode() {
|
||||
// Given a service that hasn't been enabled.
|
||||
XCTAssertFalse(service.isEnabled, "The service shouldn't be enabled to begin with.")
|
||||
#expect(!service.isEnabled, "The service shouldn't be enabled to begin with.")
|
||||
|
||||
// When setting a PIN code that is too short
|
||||
let pinCode = "abcd"
|
||||
@@ -95,116 +101,121 @@ class AppLockServiceTests: XCTestCase {
|
||||
|
||||
// Then the setup should fail and the service be left as disabled.
|
||||
guard case let .failure(error) = result else {
|
||||
XCTFail("The call should have failed.")
|
||||
Issue.record("The call should have failed.")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(error, .invalidPIN, "The PIN should be rejected as invalid.")
|
||||
XCTAssertFalse(service.isEnabled, "The service should remain disabled.")
|
||||
#expect(error == .invalidPIN, "The PIN should be rejected as invalid.")
|
||||
#expect(!service.isEnabled, "The service should remain disabled.")
|
||||
}
|
||||
|
||||
func testChangePINCode() {
|
||||
@Test
|
||||
func changePINCode() {
|
||||
// Given a service that is already enabled with a PIN.
|
||||
let pinCode = "2023"
|
||||
let newPINCode = "2024"
|
||||
guard case .success = service.setupPINCode(pinCode) else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertTrue(service.unlock(with: pinCode), "The initial PIN should work.")
|
||||
XCTAssertFalse(service.unlock(with: newPINCode), "The PIN we're about to set should not work.")
|
||||
#expect(service.isEnabled, "The service should be enabled.")
|
||||
#expect(service.unlock(with: pinCode), "The initial PIN should work.")
|
||||
#expect(!service.unlock(with: newPINCode), "The PIN we're about to set should not work.")
|
||||
|
||||
// When updating the PIN code.
|
||||
guard case .success = service.setupPINCode(newPINCode) else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
|
||||
// Then the old code should not be accepted.
|
||||
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertTrue(service.unlock(with: newPINCode), "The new PIN should work.")
|
||||
XCTAssertFalse(service.unlock(with: pinCode), "The original PIN should be rejected.")
|
||||
#expect(service.isEnabled, "The service should remain enabled.")
|
||||
#expect(service.unlock(with: newPINCode), "The new PIN should work.")
|
||||
#expect(!service.unlock(with: pinCode), "The original PIN should be rejected.")
|
||||
}
|
||||
|
||||
func testInvalidChangePINCode() {
|
||||
@Test
|
||||
func invalidChangePINCode() {
|
||||
// Given a service that is already enabled with a PIN.
|
||||
let pinCode = "2023"
|
||||
let invalidPIN = appSettings.appLockPINCodeBlockList[0]
|
||||
guard case .success = service.setupPINCode(pinCode) else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertTrue(service.unlock(with: pinCode), "The initial PIN should work.")
|
||||
XCTAssertFalse(service.unlock(with: invalidPIN), "The PIN we're about to set should not work.")
|
||||
#expect(service.isEnabled, "The service should be enabled.")
|
||||
#expect(service.unlock(with: pinCode), "The initial PIN should work.")
|
||||
#expect(!service.unlock(with: invalidPIN), "The PIN we're about to set should not work.")
|
||||
|
||||
// When updating the PIN code that is in the block list.
|
||||
let result = service.setupPINCode(invalidPIN)
|
||||
|
||||
// Then it should fail and nothing should change.
|
||||
guard case let .failure(error) = result else {
|
||||
XCTFail("The call should have failed.")
|
||||
Issue.record("The call should have failed.")
|
||||
return
|
||||
}
|
||||
XCTAssertEqual(error, .weakPIN, "The PIN should be rejected as weak.")
|
||||
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertFalse(service.unlock(with: invalidPIN), "The rejected PIN shouldn't work.")
|
||||
XCTAssertTrue(service.unlock(with: pinCode), "The original PIN should continue to work.")
|
||||
#expect(error == .weakPIN, "The PIN should be rejected as weak.")
|
||||
#expect(service.isEnabled, "The service should remain enabled.")
|
||||
#expect(!service.unlock(with: invalidPIN), "The rejected PIN shouldn't work.")
|
||||
#expect(service.unlock(with: pinCode), "The original PIN should continue to work.")
|
||||
}
|
||||
|
||||
func testDisablePINCode() {
|
||||
@Test
|
||||
func disablePINCode() {
|
||||
// Given a service that is already enabled with a PIN.
|
||||
let pinCode = "2023"
|
||||
guard case .success = service.setupPINCode(pinCode) else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertTrue(service.unlock(with: pinCode), "The initial PIN should work.")
|
||||
#expect(service.isEnabled, "The service should be enabled.")
|
||||
#expect(service.unlock(with: pinCode), "The initial PIN should work.")
|
||||
|
||||
// When disabling the PIN code.
|
||||
service.disable()
|
||||
|
||||
// Then the PIN code should be removed.
|
||||
XCTAssertFalse(service.isEnabled, "The service should no longer be enabled.")
|
||||
XCTAssertFalse(service.unlock(with: pinCode), "The initial PIN shouldn't work any more.")
|
||||
#expect(!service.isEnabled, "The service should no longer be enabled.")
|
||||
#expect(!service.unlock(with: pinCode), "The initial PIN shouldn't work any more.")
|
||||
}
|
||||
|
||||
// MARK: - Biometric Unlock
|
||||
|
||||
func testEnableBiometricUnlock() async {
|
||||
@Test
|
||||
func enableBiometricUnlock() async {
|
||||
// Given a service with the PIN code already set.
|
||||
let context = LAContextMock()
|
||||
context.biometryTypeValue = .touchID
|
||||
context.evaluatedPolicyDomainStateValue = Data("👆".utf8)
|
||||
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
|
||||
guard case .success = service.setupPINCode("2023") else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
|
||||
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should not be enabled.")
|
||||
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should not be trusted.")
|
||||
#expect(service.isEnabled, "The service should be enabled.")
|
||||
#expect(service.biometryType == .touchID, "The biometry type should be in sync with the mock.")
|
||||
#expect(!service.biometricUnlockEnabled, "Biometric unlock should not be enabled.")
|
||||
#expect(!service.biometricUnlockTrusted, "Biometric unlock should not be trusted.")
|
||||
|
||||
// When enabling biometric unlock.
|
||||
guard case .success = service.enableBiometricUnlock() else {
|
||||
XCTFail("The biometric lock should enable.")
|
||||
Issue.record("The biometric lock should enable.")
|
||||
return
|
||||
}
|
||||
context.evaluatePolicyReturnValue = true
|
||||
|
||||
// Then the service should be unlockable with biometrics.
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should now be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should now be trusted.")
|
||||
#expect(service.biometryType == .touchID, "The biometry type should not change.")
|
||||
#expect(service.biometricUnlockEnabled, "Biometric unlock should now be enabled.")
|
||||
#expect(service.biometricUnlockTrusted, "Biometric unlock should now be trusted.")
|
||||
guard await service.unlockWithBiometrics() == .unlocked else {
|
||||
XCTFail("The biometric unlock should work.")
|
||||
Issue.record("The biometric unlock should work.")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func testBiometricUnlockTrust() {
|
||||
@Test
|
||||
func biometricUnlockTrust() {
|
||||
// Given a service with the PIN code already set.
|
||||
let context = LAContextMock()
|
||||
context.biometryTypeValue = .touchID
|
||||
@@ -212,129 +223,133 @@ class AppLockServiceTests: XCTestCase {
|
||||
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
|
||||
let pinCode = "2023"
|
||||
guard case .success = service.setupPINCode(pinCode) else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
guard case .success = service.enableBiometricUnlock() else {
|
||||
XCTFail("The biometric lock should enable.")
|
||||
Issue.record("The biometric lock should enable.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
|
||||
#expect(service.isEnabled, "The service should be enabled.")
|
||||
#expect(service.biometryType == .touchID, "The biometry type should be in sync with the mock.")
|
||||
#expect(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
|
||||
#expect(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
|
||||
|
||||
// When the user changes biometric data.
|
||||
context.evaluatedPolicyDomainStateValue = Data("👈".utf8)
|
||||
|
||||
// Then biometric lock should remain enabled but untrusted.
|
||||
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
|
||||
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
|
||||
#expect(service.isEnabled, "The service should remain enabled.")
|
||||
#expect(service.biometryType == .touchID, "The biometry type should not change.")
|
||||
#expect(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
|
||||
#expect(!service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
|
||||
|
||||
// When the user confirms their PIN code.
|
||||
XCTAssertTrue(service.unlock(with: pinCode), "The PIN code should be accepted")
|
||||
#expect(service.unlock(with: pinCode), "The PIN code should be accepted")
|
||||
|
||||
// Then the biometric lock should once again be trusted.
|
||||
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should once again be trusted.")
|
||||
#expect(service.isEnabled, "The service should remain enabled.")
|
||||
#expect(service.biometryType == .touchID, "The biometry type should not change.")
|
||||
#expect(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.")
|
||||
#expect(service.biometricUnlockTrusted, "Biometric unlock should once again be trusted.")
|
||||
}
|
||||
|
||||
func testDisableBiometricUnlock() {
|
||||
@Test
|
||||
func disableBiometricUnlock() {
|
||||
// Given a service with the PIN code already set.
|
||||
let context = LAContextMock()
|
||||
context.biometryTypeValue = .touchID
|
||||
context.evaluatedPolicyDomainStateValue = Data("👆".utf8)
|
||||
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
|
||||
guard case .success = service.setupPINCode("2023") else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
guard case .success = service.enableBiometricUnlock() else {
|
||||
XCTFail("The biometric lock should enable.")
|
||||
Issue.record("The biometric lock should enable.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should be in sync with the mock.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
|
||||
#expect(service.isEnabled, "The service should be enabled.")
|
||||
#expect(service.biometryType == .touchID, "The biometry type should be in sync with the mock.")
|
||||
#expect(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
|
||||
#expect(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
|
||||
|
||||
// When disabling biometric unlock.
|
||||
service.disableBiometricUnlock()
|
||||
|
||||
// Then only PIN unlock should remain enabled.
|
||||
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertEqual(service.biometryType, .touchID, "The biometry type should not change.")
|
||||
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
|
||||
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
|
||||
#expect(service.isEnabled, "The service should remain enabled.")
|
||||
#expect(service.biometryType == .touchID, "The biometry type should not change.")
|
||||
#expect(!service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
|
||||
#expect(!service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
|
||||
}
|
||||
|
||||
func testDisablePINWithBiometricUnlock() {
|
||||
@Test
|
||||
func disablePINWithBiometricUnlock() {
|
||||
// Given a service with the PIN code already set.
|
||||
let context = LAContextMock()
|
||||
context.biometryTypeValue = .touchID
|
||||
context.evaluatedPolicyDomainStateValue = Data("👆".utf8)
|
||||
service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context)
|
||||
guard case .success = service.setupPINCode("2023") else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
guard case .success = service.enableBiometricUnlock() else {
|
||||
XCTFail("The biometric lock should enable.")
|
||||
Issue.record("The biometric lock should enable.")
|
||||
return
|
||||
}
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
|
||||
XCTAssertTrue(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
|
||||
#expect(service.isEnabled, "The service should be enabled.")
|
||||
#expect(service.biometricUnlockEnabled, "Biometric unlock should be enabled.")
|
||||
#expect(service.biometricUnlockTrusted, "Biometric unlock should be trusted.")
|
||||
|
||||
// When disabling the PIN lock.
|
||||
service.disable()
|
||||
|
||||
// Then both PIN and biometric unlock should be disabled.
|
||||
XCTAssertFalse(service.isEnabled, "The service should remain enabled.")
|
||||
XCTAssertFalse(service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
|
||||
XCTAssertFalse(service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
|
||||
#expect(!service.isEnabled, "The service should remain enabled.")
|
||||
#expect(!service.biometricUnlockEnabled, "Biometric unlock should become disabled.")
|
||||
#expect(!service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.")
|
||||
}
|
||||
|
||||
// MARK: - Attempt failures
|
||||
|
||||
func testResetAttemptsOnUnlock() {
|
||||
@Test
|
||||
func resetAttemptsOnUnlock() {
|
||||
// Given a service that is enabled and has failed unlock attempts.
|
||||
let pinCode = "2023"
|
||||
guard case .success = service.setupPINCode(pinCode) else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
appSettings.appLockNumberOfPINAttempts = 2
|
||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.")
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
#expect(appSettings.appLockNumberOfPINAttempts == 2, "The initial conditions should be stored.")
|
||||
#expect(service.isEnabled, "The service should be enabled.")
|
||||
|
||||
// When unlocking the service
|
||||
XCTAssertTrue(service.unlock(with: pinCode), "The PIN should work.")
|
||||
#expect(service.unlock(with: pinCode), "The PIN should work.")
|
||||
|
||||
// Then the attempts counts should both be reset.
|
||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
|
||||
#expect(appSettings.appLockNumberOfPINAttempts == 0, "The PIN attempts should be reset.")
|
||||
}
|
||||
|
||||
func testResetAttemptsOnDisable() {
|
||||
@Test
|
||||
func resetAttemptsOnDisable() {
|
||||
// Given a service that is enabled and has failed unlock attempts.
|
||||
let pinCode = "2023"
|
||||
guard case .success = service.setupPINCode(pinCode) else {
|
||||
XCTFail("The PIN should be valid.")
|
||||
Issue.record("The PIN should be valid.")
|
||||
return
|
||||
}
|
||||
appSettings.appLockNumberOfPINAttempts = 2
|
||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 2, "The initial conditions should be stored.")
|
||||
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
|
||||
#expect(appSettings.appLockNumberOfPINAttempts == 2, "The initial conditions should be stored.")
|
||||
#expect(service.isEnabled, "The service should be enabled.")
|
||||
|
||||
// When disabling the service
|
||||
service.disable()
|
||||
XCTAssertFalse(service.isEnabled, "The service should be disabled.")
|
||||
#expect(!service.isEnabled, "The service should be disabled.")
|
||||
|
||||
// Then the attempts counts should both be reset.
|
||||
XCTAssertEqual(appSettings.appLockNumberOfPINAttempts, 0, "The PIN attempts should be reset.")
|
||||
#expect(appSettings.appLockNumberOfPINAttempts == 0, "The PIN attempts should be reset.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,39 +7,40 @@
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import XCTest
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
class AppLockSetupSettingsScreenViewModelTests: XCTestCase {
|
||||
var appLockService: AppLockServiceProtocol!
|
||||
var keychainController: KeychainControllerMock!
|
||||
var viewModel: AppLockSetupSettingsScreenViewModelProtocol!
|
||||
@Suite
|
||||
struct AppLockSetupSettingsScreenViewModelTests {
|
||||
var appLockService: AppLockServiceProtocol
|
||||
var keychainController: KeychainControllerMock
|
||||
var viewModel: AppLockSetupSettingsScreenViewModelProtocol
|
||||
|
||||
var context: AppLockSetupSettingsScreenViewModelType.Context {
|
||||
viewModel.context
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
init() {
|
||||
keychainController = KeychainControllerMock()
|
||||
appLockService = AppLockService(keychainController: keychainController, appSettings: AppSettings())
|
||||
|
||||
viewModel = AppLockSetupSettingsScreenViewModel(appLockService: AppLockServiceMock.mock())
|
||||
}
|
||||
|
||||
func testDisablingShowsAlert() {
|
||||
@Test
|
||||
func disablingShowsAlert() {
|
||||
// Given a fresh screen with the PIN code enabled.
|
||||
let pinCode = "2023"
|
||||
keychainController.pinCodeReturnValue = pinCode
|
||||
keychainController.containsPINCodeReturnValue = true
|
||||
|
||||
XCTAssertNil(context.alertInfo)
|
||||
XCTAssertTrue(appLockService.isEnabled)
|
||||
#expect(context.alertInfo == nil)
|
||||
#expect(appLockService.isEnabled)
|
||||
|
||||
// When disabling the PIN code lock.
|
||||
context.send(viewAction: .disable)
|
||||
|
||||
// Then an alert should be shown before disabling it.
|
||||
XCTAssertNotNil(context.alertInfo)
|
||||
XCTAssertTrue(appLockService.isEnabled)
|
||||
#expect(context.alertInfo != nil)
|
||||
#expect(appLockService.isEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,19 @@
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import XCTest
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
|
||||
var appLockService: AppLockServiceMock!
|
||||
var viewModel: AppLockSetupBiometricsScreenViewModelProtocol!
|
||||
@Suite
|
||||
final class AppLockSetupBiometricsScreenViewModelTests {
|
||||
var appLockService: AppLockServiceMock
|
||||
var viewModel: AppLockSetupBiometricsScreenViewModelProtocol
|
||||
|
||||
var context: AppLockSetupBiometricsScreenViewModelType.Context {
|
||||
viewModel.context
|
||||
}
|
||||
|
||||
override func setUp() {
|
||||
init() {
|
||||
AppSettings.resetAllSettings()
|
||||
|
||||
appLockService = AppLockServiceMock()
|
||||
@@ -28,27 +29,29 @@ class AppLockSetupBiometricsScreenViewModelTests: XCTestCase {
|
||||
viewModel = AppLockSetupBiometricsScreenViewModel(appLockService: appLockService)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
deinit {
|
||||
AppSettings.resetAllSettings()
|
||||
}
|
||||
|
||||
func testAllow() async throws {
|
||||
@Test
|
||||
func allow() async throws {
|
||||
// When allowing Touch/Face ID.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
|
||||
context.send(viewAction: .allow)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the service should now have biometric unlock enabled.
|
||||
XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 1)
|
||||
#expect(appLockService.enableBiometricUnlockCallsCount == 1)
|
||||
}
|
||||
|
||||
func testSkip() async throws {
|
||||
@Test
|
||||
func skip() async throws {
|
||||
// When skipping biometrics.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .continue }
|
||||
context.send(viewAction: .skip)
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the service should now have biometric unlock enabled.
|
||||
XCTAssertEqual(appLockService.enableBiometricUnlockCallsCount, 0)
|
||||
#expect(appLockService.enableBiometricUnlockCallsCount == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import XCTest
|
||||
import Testing
|
||||
|
||||
@MainActor
|
||||
class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
||||
@Suite
|
||||
final class AppLockSetupPINScreenViewModelTests {
|
||||
var appLockService: AppLockService!
|
||||
var keychainController: KeychainControllerMock!
|
||||
var viewModel: AppLockSetupPINScreenViewModelProtocol!
|
||||
@@ -19,42 +20,40 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
||||
viewModel.context
|
||||
}
|
||||
|
||||
override func setUp() {
|
||||
AppSettings.resetAllSettings()
|
||||
keychainController = KeychainControllerMock()
|
||||
appLockService = AppLockService(keychainController: keychainController, appSettings: AppSettings())
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
deinit {
|
||||
AppSettings.resetAllSettings()
|
||||
}
|
||||
|
||||
func testCreatePIN() async throws {
|
||||
@Test
|
||||
func createPIN() async throws {
|
||||
setup(mode: .create)
|
||||
|
||||
// Given the screen in create mode.
|
||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
|
||||
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
|
||||
#expect(context.viewState.mode == .create, "The mode should start as creation.")
|
||||
|
||||
// When entering an new PIN.
|
||||
let createDeferred = deferFulfillment(context.$viewState, message: "A valid PIN needs confirming.") { $0.mode == .confirm }
|
||||
let createDeferred = deferFulfillment(context.$viewState) { $0.mode == .confirm }
|
||||
context.pinCode = "2023"
|
||||
try await createDeferred.fulfill()
|
||||
|
||||
// Then the screen should transition to the confirm mode.
|
||||
XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.")
|
||||
#expect(context.viewState.mode == .confirm, "The mode should transition to confirmation.")
|
||||
|
||||
// When re-entering that PIN.
|
||||
let confirmDeferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
|
||||
let confirmDeferred = deferFulfillment(viewModel.actions) { $0 == .complete }
|
||||
context.pinCode = "2023"
|
||||
|
||||
// Then the screen should signal it is complete.
|
||||
try await confirmDeferred.fulfill()
|
||||
}
|
||||
|
||||
func testCreateWeakPIN() async throws {
|
||||
@Test
|
||||
func createWeakPIN() async throws {
|
||||
setup(mode: .create)
|
||||
|
||||
// Given the screen in create mode.
|
||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
|
||||
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
|
||||
XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.")
|
||||
#expect(context.viewState.mode == .create, "The mode should start as creation.")
|
||||
#expect(context.alertInfo == nil, "There shouldn't be an alert to begin with.")
|
||||
|
||||
// When entering a weak PIN on the blocklist.
|
||||
let deferred = deferFulfillment(context.$viewState) { $0.bindings.alertInfo != nil }
|
||||
@@ -62,22 +61,24 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the PIN should be rejected and the user alerted.
|
||||
XCTAssertEqual(context.alertInfo?.id, .weakPIN, "The weak PIN should be rejected.")
|
||||
XCTAssertEqual(context.viewState.mode, .create, "The mode shouldn't transition after an invalid PIN code.")
|
||||
#expect(context.alertInfo?.id == .weakPIN, "The weak PIN should be rejected.")
|
||||
#expect(context.viewState.mode == .create, "The mode shouldn't transition after an invalid PIN code.")
|
||||
}
|
||||
|
||||
func testCreatePINMismatch() async throws {
|
||||
// Given the confirm mode after entering a new PIN.
|
||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
|
||||
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
|
||||
XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.")
|
||||
@Test
|
||||
func createPINMismatch() async throws {
|
||||
setup(mode: .create)
|
||||
|
||||
let createDeferred = deferFulfillment(context.$viewState, message: "A valid PIN needs confirming.") { $0.mode == .confirm }
|
||||
// Given the confirm mode after entering a new PIN.
|
||||
#expect(context.viewState.mode == .create, "The mode should start as creation.")
|
||||
#expect(context.alertInfo == nil, "There shouldn't be an alert to begin with.")
|
||||
|
||||
let createDeferred = deferFulfillment(context.$viewState) { $0.mode == .confirm }
|
||||
context.pinCode = "2023"
|
||||
try await createDeferred.fulfill()
|
||||
XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.")
|
||||
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 0, "The mode should start with zero attempts.")
|
||||
XCTAssertNil(context.alertInfo, "There shouldn't be an alert after a valid initial PIN.")
|
||||
#expect(context.viewState.mode == .confirm, "The mode should transition to confirmation.")
|
||||
#expect(context.viewState.numberOfConfirmAttempts == 0, "The mode should start with zero attempts.")
|
||||
#expect(context.alertInfo == nil, "There shouldn't be an alert after a valid initial PIN.")
|
||||
|
||||
// When entering the new PIN incorrectly
|
||||
var deferred = deferFulfillment(context.$viewState) { $0.numberOfConfirmAttempts == 1 }
|
||||
@@ -85,8 +86,8 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the user should be alerted.
|
||||
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 1, "The mismatch should be counted.")
|
||||
XCTAssertEqual(context.alertInfo?.id, .pinMismatch, "A PIN mismatch should be rejected.")
|
||||
#expect(context.viewState.numberOfConfirmAttempts == 1, "The mismatch should be counted.")
|
||||
#expect(context.alertInfo?.id == .pinMismatch, "A PIN mismatch should be rejected.")
|
||||
|
||||
// When dismissing the alert and repeating twice more.
|
||||
context.alertInfo?.primaryButton.action?()
|
||||
@@ -97,42 +98,46 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
||||
deferred = deferFulfillment(context.$viewState) { $0.numberOfConfirmAttempts == 3 }
|
||||
context.pinCode = "2024"
|
||||
try await deferred.fulfill()
|
||||
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 3, "All the mismatches should be counted.")
|
||||
XCTAssertEqual(context.alertInfo?.id, .pinMismatch, "A PIN mismatch should be rejected.")
|
||||
#expect(context.viewState.numberOfConfirmAttempts == 3, "All the mismatches should be counted.")
|
||||
#expect(context.alertInfo?.id == .pinMismatch, "A PIN mismatch should be rejected.")
|
||||
|
||||
// Then tapping the alert button should reset back to create mode.
|
||||
context.alertInfo?.primaryButton.action?()
|
||||
XCTAssertEqual(context.viewState.mode, .create, "The mode should revert back to creation.")
|
||||
#expect(context.viewState.mode == .create, "The mode should revert back to creation.")
|
||||
}
|
||||
|
||||
func testUnlock() async throws {
|
||||
@Test
|
||||
func unlock() async throws {
|
||||
setup(mode: .unlock)
|
||||
|
||||
// Given the screen in unlock mode.
|
||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService)
|
||||
let pinCode = "2023"
|
||||
keychainController.pinCodeReturnValue = pinCode
|
||||
keychainController.containsPINCodeReturnValue = true
|
||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||
|
||||
// When entering the configured PIN.
|
||||
let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .complete }
|
||||
context.pinCode = pinCode
|
||||
|
||||
// Then the screen should signal it is complete.
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
|
||||
func testForgotPIN() async throws {
|
||||
@Test
|
||||
func forgotPIN() async throws {
|
||||
setup(mode: .unlock)
|
||||
|
||||
// Given the screen in unlock mode.
|
||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService)
|
||||
XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.")
|
||||
XCTAssertFalse(context.viewState.isLoggingOut, "The view should not start disabled.")
|
||||
#expect(context.alertInfo == nil, "There shouldn't be an alert to begin with.")
|
||||
#expect(!context.viewState.isLoggingOut, "The view should not start disabled.")
|
||||
|
||||
// When the user has forgotten their PIN.
|
||||
context.send(viewAction: .forgotPIN)
|
||||
|
||||
// Then an alert should be shown before logging out.
|
||||
XCTAssertEqual(context.alertInfo?.id, .confirmResetPIN, "The weak PIN should be rejected.")
|
||||
XCTAssertFalse(context.viewState.isLoggingOut, "The view should not be disabled until the user confirms.")
|
||||
#expect(context.alertInfo?.id == .confirmResetPIN, "The weak PIN should be rejected.")
|
||||
#expect(!context.viewState.isLoggingOut, "The view should not be disabled until the user confirms.")
|
||||
|
||||
// When confirming the logout.
|
||||
let deferred = deferFulfillment(viewModel.actions) { $0 == .forceLogout }
|
||||
@@ -140,44 +145,52 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
|
||||
|
||||
// Then a force logout should be initiated.
|
||||
try await deferred.fulfill()
|
||||
XCTAssertTrue(context.viewState.isLoggingOut, "The view should become disabled.")
|
||||
#expect(context.viewState.isLoggingOut, "The view should become disabled.")
|
||||
}
|
||||
|
||||
func testUnlockFailed() async throws {
|
||||
@Test
|
||||
func unlockFailed() async throws {
|
||||
setup(mode: .unlock)
|
||||
|
||||
// Given the screen in unlock mode.
|
||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService)
|
||||
keychainController.pinCodeReturnValue = "2023"
|
||||
keychainController.containsPINCodeReturnValue = true
|
||||
keychainController.containsPINCodeBiometricStateReturnValue = false
|
||||
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 0, "The screen should start with zero attempts.")
|
||||
XCTAssertFalse(context.viewState.isSubtitleWarning, "The subtitle should start without a warning.")
|
||||
XCTAssertFalse(context.viewState.isLoggingOut, "The view should not start disabled.")
|
||||
#expect(context.viewState.numberOfUnlockAttempts == 0, "The screen should start with zero attempts.")
|
||||
#expect(!context.viewState.isSubtitleWarning, "The subtitle should start without a warning.")
|
||||
#expect(!context.viewState.isLoggingOut, "The view should not start disabled.")
|
||||
|
||||
// When entering a different PIN.
|
||||
var deferred = deferFulfillment(context.$viewState, keyPath: \.bindings.pinCode, transitionValues: ["", "2024", ""],
|
||||
message: "The PIN should be entered and then cleared by the view model.")
|
||||
var deferred = deferFulfillment(context.$viewState, keyPath: \.bindings.pinCode, transitionValues: ["", "2024", ""])
|
||||
context.pinCode = "2024"
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the PIN should be rejected and the user notified.
|
||||
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 1, "An invalid attempt should be counted.")
|
||||
XCTAssertTrue(context.viewState.isSubtitleWarning, "The subtitle should then show a warning.")
|
||||
XCTAssertFalse(context.viewState.isLoggingOut, "The view should still work.")
|
||||
#expect(context.viewState.numberOfUnlockAttempts == 1, "An invalid attempt should be counted.")
|
||||
#expect(context.viewState.isSubtitleWarning, "The subtitle should then show a warning.")
|
||||
#expect(!context.viewState.isLoggingOut, "The view should still work.")
|
||||
|
||||
// When entering the same incorrect PIN twice more
|
||||
deferred = deferFulfillment(context.$viewState, keyPath: \.bindings.pinCode, transitionValues: ["", "2024", ""],
|
||||
message: "The PIN should be entered and then cleared by the view model.")
|
||||
deferred = deferFulfillment(context.$viewState, keyPath: \.bindings.pinCode, transitionValues: ["", "2024", ""])
|
||||
context.pinCode = "2024"
|
||||
try await deferred.fulfill()
|
||||
deferred = deferFulfillment(context.$viewState, keyPath: \.bindings.pinCode, transitionValues: ["", "2024", ""],
|
||||
message: "The PIN should be entered and then cleared by the view model.")
|
||||
deferred = deferFulfillment(context.$viewState, keyPath: \.bindings.pinCode, transitionValues: ["", "2024", ""])
|
||||
context.pinCode = "2024"
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the user should be alerted that they're being signed out.
|
||||
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 3, "All invalid attempts should be counted.")
|
||||
XCTAssertTrue(context.viewState.isSubtitleWarning, "The subtitle should continue showing a warning.")
|
||||
XCTAssertEqual(context.alertInfo?.id, .forceLogout, "An alert should be shown about a force logout.")
|
||||
XCTAssertTrue(context.viewState.isLoggingOut, "The view should become disabled.")
|
||||
#expect(context.viewState.numberOfUnlockAttempts == 3, "All invalid attempts should be counted.")
|
||||
#expect(context.viewState.isSubtitleWarning, "The subtitle should continue showing a warning.")
|
||||
#expect(context.alertInfo?.id == .forceLogout, "An alert should be shown about a force logout.")
|
||||
#expect(context.viewState.isLoggingOut, "The view should become disabled.")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func setup(mode: AppLockSetupPINScreenMode) {
|
||||
AppSettings.resetAllSettings()
|
||||
keychainController = KeychainControllerMock()
|
||||
appLockService = AppLockService(keychainController: keychainController, appSettings: AppSettings())
|
||||
viewModel = AppLockSetupPINScreenViewModel(initialMode: mode, isMandatory: false, appLockService: appLockService)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,150 +7,155 @@
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import XCTest
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
class AppLockTimerTests: XCTestCase {
|
||||
var timer: AppLockTimer!
|
||||
|
||||
let now = Date.now
|
||||
@Suite
|
||||
struct AppLockTimerTests {
|
||||
private let now = Date.now
|
||||
private var timer: AppLockTimer!
|
||||
|
||||
var gracePeriod: TimeInterval {
|
||||
timer.gracePeriod
|
||||
}
|
||||
|
||||
|
||||
var halfGracePeriod: TimeInterval {
|
||||
gracePeriod / 2
|
||||
timer.gracePeriod / 2
|
||||
}
|
||||
|
||||
|
||||
var gracePeriodX2: TimeInterval {
|
||||
gracePeriod * 2
|
||||
timer.gracePeriod * 2
|
||||
}
|
||||
|
||||
|
||||
var gracePeriodX10: TimeInterval {
|
||||
gracePeriod * 10
|
||||
timer.gracePeriod * 10
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
timer = nil
|
||||
@Test
|
||||
mutating func timerLockedOnStartup() {
|
||||
setupTimer(unlocked: false)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now),
|
||||
"The app should be locked on a fresh launch.")
|
||||
|
||||
setupTimer(unlocked: false)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + 1),
|
||||
"The app should be locked after a fresh launch.")
|
||||
|
||||
setupTimer(unlocked: false)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
|
||||
"The app should be locked after a fresh launch.")
|
||||
|
||||
setupTimer(unlocked: false)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
|
||||
"The app should be locked after a fresh launch.")
|
||||
|
||||
setupTimer(unlocked: false)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
|
||||
"The app should be locked after a fresh launch.")
|
||||
}
|
||||
|
||||
func testTimerLockedOnStartup() {
|
||||
setupTimer(unlocked: false)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now),
|
||||
"The app should be locked on a fresh launch.")
|
||||
@Test
|
||||
mutating func timerBeforeFirstUnlock() {
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
|
||||
setupTimer(unlocked: false)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + 1),
|
||||
"The app should be locked after a fresh launch.")
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + 1),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
|
||||
setupTimer(unlocked: false)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
|
||||
"The app should be locked after a fresh launch.")
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
|
||||
setupTimer(unlocked: false)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
|
||||
"The app should be locked after a fresh launch.")
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
|
||||
setupTimer(unlocked: false)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
|
||||
"The app should be locked after a fresh launch.")
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
}
|
||||
|
||||
func testTimerBeforeFirstUnlock() {
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + 1),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
|
||||
setupTimer(unlocked: false, backgroundedAt: now)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
|
||||
"The app should always remain locked after backgrounding when locked.")
|
||||
}
|
||||
|
||||
func testTimerWhenUnlocked() {
|
||||
@Test
|
||||
mutating func timerWhenUnlocked() {
|
||||
setupTimer(unlocked: true, backgroundedAt: now)
|
||||
XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: now + 1),
|
||||
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
|
||||
#expect(!timer.computeLockState(didBecomeActiveAt: now + 1),
|
||||
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
|
||||
|
||||
setupTimer(unlocked: true, backgroundedAt: now)
|
||||
XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
|
||||
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
|
||||
#expect(!timer.computeLockState(didBecomeActiveAt: now + halfGracePeriod),
|
||||
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
|
||||
|
||||
setupTimer(unlocked: true, backgroundedAt: now)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
|
||||
"The app should become locked when it was unlocked and backgrounded for more than the grace period.")
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + gracePeriod),
|
||||
"The app should become locked when it was unlocked and backgrounded for more than the grace period.")
|
||||
|
||||
setupTimer(unlocked: true, backgroundedAt: now)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
|
||||
"The app should become locked when it was unlocked and backgrounded for more than the grace period.")
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + gracePeriodX10),
|
||||
"The app should become locked when it was unlocked and backgrounded for more than the grace period.")
|
||||
}
|
||||
|
||||
func testTimerRepeatingWithinGracePeriod() {
|
||||
@Test
|
||||
mutating func timerRepeatingWithinGracePeriod() {
|
||||
setupTimer(unlocked: true, backgroundedAt: now)
|
||||
|
||||
var nextCheck = now + halfGracePeriod
|
||||
XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
|
||||
#expect(!timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
|
||||
timer.applicationDidEnterBackground(date: nextCheck)
|
||||
|
||||
nextCheck = now + gracePeriod
|
||||
XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
|
||||
#expect(!timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
|
||||
timer.applicationDidEnterBackground(date: nextCheck)
|
||||
|
||||
nextCheck = now + gracePeriod + halfGracePeriod
|
||||
XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
|
||||
#expect(!timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
|
||||
timer.applicationDidEnterBackground(date: nextCheck)
|
||||
|
||||
nextCheck = now + gracePeriodX2
|
||||
XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
|
||||
#expect(!timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
|
||||
timer.applicationDidEnterBackground(date: nextCheck)
|
||||
|
||||
nextCheck = now + gracePeriodX10
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should become locked however when finally staying backgrounded for longer than the grace period.")
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: nextCheck),
|
||||
"The app should become locked however when finally staying backgrounded for longer than the grace period.")
|
||||
}
|
||||
|
||||
func testTimerWithLongForeground() {
|
||||
@Test
|
||||
mutating func timerWithLongForeground() {
|
||||
setupTimer(unlocked: true)
|
||||
|
||||
let backgroundDate = now + gracePeriodX10
|
||||
timer.applicationDidEnterBackground(date: backgroundDate)
|
||||
|
||||
XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: backgroundDate + 1),
|
||||
"The grace period should be measured from the time the app was backgrounded, and not when it was unlocked.")
|
||||
#expect(!timer.computeLockState(didBecomeActiveAt: backgroundDate + 1),
|
||||
"The grace period should be measured from the time the app was backgrounded, and not when it was unlocked.")
|
||||
}
|
||||
|
||||
func testChangingTimeLocksApp() {
|
||||
@Test
|
||||
mutating func changingTimeLocksApp() {
|
||||
setupTimer(unlocked: true, backgroundedAt: now)
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now - 1),
|
||||
"The the device's clock is changed to before the app was backgrounded, the device should remain locked.")
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now - 1),
|
||||
"The the device's clock is changed to before the app was backgrounded, the device should remain locked.")
|
||||
}
|
||||
|
||||
func testNoGracePeriod() {
|
||||
@Test
|
||||
mutating func noGracePeriod() {
|
||||
// Given a timer with no grace period that is in the background.
|
||||
setupTimer(gracePeriod: 0, unlocked: true)
|
||||
let backgroundDate = now + 1
|
||||
timer.applicationDidEnterBackground(date: backgroundDate)
|
||||
|
||||
// Then the app should be locked immediately.
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: backgroundDate))
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: backgroundDate))
|
||||
}
|
||||
|
||||
func testResignActive() {
|
||||
@Test
|
||||
mutating func resignActive() {
|
||||
// Given a timer with no grace period.
|
||||
setupTimer(gracePeriod: 0, unlocked: true)
|
||||
|
||||
@@ -158,36 +163,32 @@ class AppLockTimerTests: XCTestCase {
|
||||
timer.applicationDidEnterBackground(date: now)
|
||||
|
||||
// Then the app should be locked.
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + 1))
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + 1))
|
||||
|
||||
// When the app resigns active but doesn't enter the background.
|
||||
// (Nothing to do here, we just don't call applicationDidEnterBackground).
|
||||
|
||||
// Then the app should also remain locked.
|
||||
XCTAssertTrue(timer.computeLockState(didBecomeActiveAt: now + 2))
|
||||
#expect(timer.computeLockState(didBecomeActiveAt: now + 2))
|
||||
|
||||
// When unlocking the app and resigning active (but not entering the background)
|
||||
timer.registerUnlock()
|
||||
// (Again, nothing to do here for resigning active)
|
||||
|
||||
// Then the app should not become locked.
|
||||
XCTAssertFalse(timer.computeLockState(didBecomeActiveAt: now + 3))
|
||||
#expect(!timer.computeLockState(didBecomeActiveAt: now + 3))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Sets up the timer for testing.
|
||||
/// - Parameters:
|
||||
/// - gracePeriod: Set up the test with a custom grace period for the timer. Defaults to 3 minutes.
|
||||
/// - unlocked: Whether the timer should consider itself unlocked or not.
|
||||
/// - backgroundedDate: If not nil, the timer will consider the app to have been backgrounded at the specified date.
|
||||
private func setupTimer(gracePeriod: TimeInterval = 180, unlocked: Bool, backgroundedAt backgroundedDate: Date? = nil) {
|
||||
timer = AppLockTimer(gracePeriod: gracePeriod)
|
||||
private mutating func setupTimer(gracePeriod: TimeInterval = 180, unlocked: Bool, backgroundedAt backgroundedDate: Date? = nil) {
|
||||
let timer = AppLockTimer(gracePeriod: gracePeriod)
|
||||
if unlocked {
|
||||
timer.registerUnlock()
|
||||
}
|
||||
if let backgroundedDate {
|
||||
timer.applicationDidEnterBackground(date: backgroundedDate)
|
||||
}
|
||||
self.timer = timer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,18 @@
|
||||
//
|
||||
|
||||
@testable import ElementX
|
||||
import XCTest
|
||||
import Testing
|
||||
|
||||
class PINTextFieldTests: XCTestCase {
|
||||
func testSanitize() {
|
||||
@Suite
|
||||
struct PINTextFieldTests {
|
||||
@Test
|
||||
func sanitize() {
|
||||
let textField = PINTextField(pinCode: .constant(""))
|
||||
XCTAssertEqual(textField.sanitize("2"), "2")
|
||||
XCTAssertEqual(textField.sanitize("2023"), "2023")
|
||||
XCTAssertEqual(textField.sanitize("20233"), "2023")
|
||||
XCTAssertEqual(textField.sanitize("20x"), "20")
|
||||
XCTAssertEqual(textField.sanitize("20!"), "20")
|
||||
XCTAssertEqual(textField.sanitize("boop"), "")
|
||||
#expect(textField.sanitize("2") == "2")
|
||||
#expect(textField.sanitize("2023") == "2023")
|
||||
#expect(textField.sanitize("20233") == "2023")
|
||||
#expect(textField.sanitize("20x") == "20")
|
||||
#expect(textField.sanitize("20!") == "20")
|
||||
#expect(textField.sanitize("boop") == "")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user