// // LoginViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/20. // import XCTest @testable import suixinkan @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)! ) } }