Files
suixinkan_ios_uikit/suixinkan_iosTests/LoginViewModelTests.swift
2026-06-26 14:33:31 +08:00

409 lines
15 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// LoginViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/20.
//
import XCTest
@testable import suixinkan_ios
@MainActor
/// ViewModel
final class LoginViewModelTests: XCTestCase {
/// +86 11
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
let viewModel = LoginViewModel()
viewModel.username = "+86 186 5185 7230"
viewModel.normalizeUsernameCountryCodeIfNeeded()
XCTAssertEqual(viewModel.username, "18651857230")
XCTAssertEqual(viewModel.normalizedUsername, "18651857230")
XCTAssertTrue(viewModel.isValidPhone)
}
///
func testCanSubmitRequiresValidPhoneAndNonEmptyPassword() {
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = " secret123 "
XCTAssertTrue(viewModel.canSubmit)
XCTAssertEqual(viewModel.trimmedPassword, "secret123")
}
///
func testCanSubmitIsFalseForInvalidPhone() {
let viewModel = LoginViewModel()
viewModel.username = "12345"
viewModel.password = "secret123"
XCTAssertFalse(viewModel.canSubmit)
XCTAssertFalse(viewModel.isValidPhone)
}
///
func testValidateForLoginReturnsExpectedErrorsInOrder() {
let viewModel = LoginViewModel()
viewModel.username = "12345"
viewModel.password = "secret123"
viewModel.privacyChecked = true
XCTAssertEqual(viewModel.validateForLogin(), .invalidPhone)
viewModel.username = "18651857230"
viewModel.password = " "
XCTAssertEqual(viewModel.validateForLogin(), .emptyPassword)
viewModel.password = "secret123"
viewModel.privacyChecked = false
XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked)
viewModel.privacyChecked = true
XCTAssertNil(viewModel.validateForLogin())
}
///
func testValidateForLoginRequiresPrivacyAgreement() {
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = false
XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked)
}
///
func testApplyPreferencesRestoresUsernameAndPrivacyOnly() {
let viewModel = LoginViewModel()
viewModel.password = "manual-password"
viewModel.applyPreferences(
LoginPreferences(
lastUsername: "18651857230",
privacyAgreementAccepted: true
)
)
XCTAssertEqual(viewModel.username, "18651857230")
XCTAssertTrue(viewModel.privacyChecked)
XCTAssertEqual(viewModel.password, "manual-password")
}
///
func testApplyPreferencesDoesNotOverwriteExistingUsername() {
let viewModel = LoginViewModel()
viewModel.username = "13900139000"
viewModel.applyPreferences(
LoginPreferences(
lastUsername: "18651857230",
privacyAgreementAccepted: false
)
)
XCTAssertEqual(viewModel.username, "13900139000")
XCTAssertFalse(viewModel.privacyChecked)
}
///
func testAcceptAgreementChecksPrivacyAndDismissesSheet() {
let viewModel = LoginViewModel()
viewModel.showsAgreementSheet = true
viewModel.acceptAgreement()
XCTAssertTrue(viewModel.privacyChecked)
XCTAssertFalse(viewModel.showsAgreementSheet)
}
/// set-user completed
func testLoginWithSingleAccountAutoCompletesSetUser() async throws {
let session = LoginRecordingURLSession(responses: [
try TestFixture.data(named: "v9_login_single_scenic_success"),
try TestFixture.data(named: "v9_set_scenic_user_success")
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
let resolution = try await viewModel.login(authAPI: api)
guard case .completed(let response) = resolution else {
return XCTFail("单账号登录应直接完成")
}
XCTAssertEqual(response.token, "business-token-scenic")
XCTAssertFalse(viewModel.isLoading)
XCTAssertNil(viewModel.pendingAccountSelection)
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/app/v9/login",
"/api/app/v9/set-user"
])
let loginBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[0].httpBody)) as? [String: Any])
XCTAssertEqual(loginBody["username"] as? String, "18651857230")
let setUserBody = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests[1].httpBody)) as? [String: Any])
XCTAssertEqual(setUserBody["ss_user_id"] as? Int, 101)
XCTAssertNil(setUserBody["store_user_id"] as? Int)
}
/// needsAccountSelection
func testLoginWithMultipleAccountsNeedsSelection() async throws {
let session = LoginRecordingURLSession(responses: [
try TestFixture.data(named: "v9_login_multi_success")
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
let resolution = try await viewModel.login(authAPI: api)
guard case .needsAccountSelection(let payload) = resolution else {
return XCTFail("多账号登录应进入账号选择")
}
XCTAssertEqual(payload.tempToken, "person-temp-token")
XCTAssertEqual(payload.accounts.map(\.businessUserId), [101, 201])
XCTAssertEqual(viewModel.pendingAccountSelection, payload)
XCTAssertEqual(session.requests.count, 1)
XCTAssertEqual(session.requests.first?.url?.path, "/api/app/v9/login")
}
/// noAvailableAccount
func testLoginThrowsWhenNoAvailableAccounts() async throws {
let session = LoginRecordingURLSession(responses: [
"""
{
"code": 100000,
"msg": "success",
"data": {
"token": "person-temp-token",
"scenic_users": [],
"store_users": []
}
}
""".data(using: .utf8)!
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
do {
_ = try await viewModel.login(authAPI: api)
XCTFail("没有可用账号时应抛出错误")
} catch let error as LoginFlowError {
guard case .noAvailableAccount = error else {
return XCTFail("期望 noAvailableAccount实际为 \(error)")
}
}
XCTAssertFalse(viewModel.isLoading)
}
/// token missingToken
func testLoginThrowsWhenTokenMissing() async throws {
let session = LoginRecordingURLSession(responses: [
"""
{
"code": 100000,
"msg": "success",
"data": {
"token": "",
"scenic_users": [
{
"account_type": "scenic_user",
"id": 101,
"user_id": 101,
"scenic_user_id": 101,
"ss_user_id": 101,
"username": "scenic_admin",
"real_name": "",
"nickname": "",
"phone": "13800138000",
"scenic_id": 10,
"scenic_name": "",
"is_current": false
}
],
"store_users": []
}
}
""".data(using: .utf8)!
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
do {
_ = try await viewModel.login(authAPI: api)
XCTFail("缺少 token 时应抛出错误")
} catch let error as LoginFlowError {
guard case .missingToken = error else {
return XCTFail("期望 missingToken实际为 \(error)")
}
}
}
/// CancellationError
func testLoginWhileLoadingThrowsCancellationError() async throws {
let session = LoginRecordingURLSession(responses: [
try TestFixture.data(named: "v9_login_single_scenic_success"),
try TestFixture.data(named: "v9_set_scenic_user_success")
])
let api = AuthAPI(client: APIClient(session: session))
let viewModel = LoginViewModel()
viewModel.username = "18651857230"
viewModel.password = "secret123"
viewModel.privacyChecked = true
viewModel.isLoading = true
do {
_ = try await viewModel.login(authAPI: api)
XCTFail("加载中重复登录应被取消")
} catch {
XCTAssertTrue(error is CancellationError)
}
}
/// token set-user
func testSelectAccountCompletesWithFinalToken() async throws {
let session = LoginRecordingURLSession(responses: [
try TestFixture.data(named: "v9_set_store_user_success")
])
let api = AuthAPI(client: APIClient(session: session))
let account = AccountSwitchAccount(
accountType: V9StoreUser.accountTypeValue,
businessUserId: 201,
title: "示例门店",
subtitle: "示例景区",
phone: "13800138000",
avatar: "",
scenicName: "示例景区",
storeId: 20,
storeName: "示例门店",
scenicId: 10,
isCurrent: false
)
let viewModel = LoginViewModel()
viewModel.pendingAccountSelection = AccountSelectionPayload(
tempToken: "person-temp-token",
accounts: [account]
)
let response = try await viewModel.selectAccount(account, authAPI: api)
XCTAssertEqual(response.token, "business-token-store")
XCTAssertNil(viewModel.pendingAccountSelection)
XCTAssertFalse(viewModel.isSelectingAccount)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.url?.path, "/api/app/v9/set-user")
XCTAssertEqual(request.value(forHTTPHeaderField: "token"), "person-temp-token")
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any])
XCTAssertEqual(json["store_user_id"] as? Int, 201)
}
/// selectAccount missingToken
func testSelectAccountThrowsWhenPendingSelectionMissing() async {
let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: [])))
let account = AccountSwitchAccount(
accountType: V9StoreUser.accountTypeValue,
businessUserId: 201,
title: "示例门店",
subtitle: "示例景区",
phone: "13800138000",
avatar: "",
scenicName: "示例景区",
storeId: 20,
storeName: "示例门店",
scenicId: 10,
isCurrent: false
)
let viewModel = LoginViewModel()
do {
_ = try await viewModel.selectAccount(account, authAPI: api)
XCTFail("缺少待选账号时应抛出错误")
} catch let error as LoginFlowError {
guard case .missingToken = error else {
return XCTFail("期望 missingToken实际为 \(error)")
}
} catch {
XCTFail("意外错误: \(error)")
}
}
/// ID selectAccount invalidAccount
func testSelectAccountThrowsWhenAccountInvalid() async {
let api = AuthAPI(client: APIClient(session: LoginRecordingURLSession(responses: [])))
let account = AccountSwitchAccount(
accountType: V9StoreUser.accountTypeValue,
businessUserId: 0,
title: "无效账号",
subtitle: "",
phone: "",
avatar: "",
scenicName: "",
storeId: nil,
storeName: "",
scenicId: nil,
isCurrent: false
)
let viewModel = LoginViewModel()
viewModel.pendingAccountSelection = AccountSelectionPayload(
tempToken: "person-temp-token",
accounts: [account]
)
do {
_ = try await viewModel.selectAccount(account, authAPI: api)
XCTFail("无效账号 ID 时应抛出错误")
} catch let error as LoginFlowError {
guard case .invalidAccount = error else {
return XCTFail("期望 invalidAccount实际为 \(error)")
}
} catch {
XCTFail("意外错误: \(error)")
}
}
///
func testClearPendingAccountSelection() {
let viewModel = LoginViewModel()
viewModel.pendingAccountSelection = AccountSelectionPayload(
tempToken: "person-temp-token",
accounts: []
)
viewModel.clearPendingAccountSelection()
XCTAssertNil(viewModel.pendingAccountSelection)
}
}
/// URLSession
private final class LoginRecordingURLSession: URLSessionProtocol {
private var responses: [Data]
private(set) var requests: [URLRequest] = []
///
init(responses: [Data]) {
self.responses = responses
}
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
let data = responses.isEmpty ? Data() : responses.removeFirst()
return (
data,
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
)
}
}