// // LoginViewModelTests.swift // suixinkanTests // import UIKit import XCTest @testable import suixinkan @MainActor /// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和登录流程。 final class LoginViewModelTests: XCTestCase { /// 之前按手机号保存的 Mock 完成记录不能阻断旧接口下仍可用的其他业务身份。 func testLegacyPhoneWideMockRecordDoesNotBlockServerLogin() async throws { let username = "13900000001" let key = "account_deletion_mock_v1_" + username let defaults = UserDefaults.standard let previous = defaults.object(forKey: key) defer { if let previous { defaults.set(previous, forKey: key) } else { defaults.removeObject(forKey: key) } } let oldRecord: [String: Any] = [ "id": UUID().uuidString, "clientRequestID": UUID().uuidString, "username": username, "submittedAt": 0, "scheduledDeletionAt": 0, "status": "completed", "acknowledgedAssetKinds": [], ] defaults.set(try JSONSerialization.data(withJSONObject: oldRecord), forKey: key) let login = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[{"id":101,"store_user_id":101,"store_id":25,"store_name":"另一门店"}]}}"#.utf8) let selected = Data(#"{"code":100000,"data":{"token":"business","scenic_users":[],"store_users":[]}}"#.utf8) let session = MockURLSession(responses: [login, selected]) let viewModel = LoginViewModel() viewModel.updateAccount(username) viewModel.updatePassword("test-password") let api = AuthAPI(client: APIClient(environment: .testing, session: session)) let result = try await viewModel.login(authAPI: api) guard case let .completed(response, account) = result else { return XCTFail("可用门店身份应正常登录") } XCTAssertEqual(response.token, "business") XCTAssertEqual(account.businessUserId, 101) XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login", "/api/app/v9/set-user"]) } /// 普通门店身份选择直接回调,不再无条件弹出注销提示。 func testSelectingStoreIdentityContinuesWithoutDeregistrationPrompt() throws { let account = reentryAccount(isStore: true) var confirmed: [AccountSwitchAccount] = [] let controller = AccountSelectionViewController( payload: AccountSelectionPayload(tempToken: "temporary", accounts: [account]), isLoading: false, onCancel: {}, onConfirm: { confirmed.append($0) } ) controller.loadViewIfNeeded() let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton } .first { $0.currentTitle == "进入系统" }) button.sendActions(for: .touchUpInside) XCTAssertEqual(confirmed, [account]) XCTAssertNil(controller.presentedViewController) } /// 登录按钮和键盘完成键均直接请求登录;空身份响应避免写入真实会话。 func testLoginPageRequestsLoginWithoutDeregistrationPrompt() async throws { for useKeyboard in [false, true] { let response = Data(#"{"code":100000,"data":{"token":"temporary","scenic_users":[],"store_users":[]}}"#.utf8) let session = MockURLSession(responses: [response]) let controller = LoginViewController(authAPI: AuthAPI(client: APIClient(environment: .testing, session: session))) controller.loadViewIfNeeded() let views = allSubviews(in: controller.view) let account = try XCTUnwrap(views.compactMap { $0 as? LoginTextField }.first) let password = try XCTUnwrap(views.compactMap { $0 as? PasswordInputField }.first) let agreement = try XCTUnwrap(views.compactMap { $0 as? AgreementRowView }.first) account.text = "13900000001" account.onTextChange?(account.text) password.text = "mock-password" password.onTextChange?(password.text) if !agreement.isChecked { agreement.onCheckedChange?() } let button = try XCTUnwrap(views.compactMap { $0 as? UIButton }.first { $0.currentTitle == "登录" }) if useKeyboard { password.onReturnKey?() } else { button.sendActions(for: .touchUpInside) } for _ in 0..<100 { if !session.requests.isEmpty, button.isEnabled { break } try await Task.sleep(nanoseconds: 10_000_000) } XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/app/v9/login"]) XCTAssertNil(controller.presentedViewController) } } /// 移除注销提示后仍必须勾选用户协议,不能绕过原有表单校验。 func testLoginPageStillRequiresPrivacyAgreement() async throws { let session = MockURLSession(responses: []) let controller = LoginViewController(authAPI: AuthAPI(client: APIClient(environment: .testing, session: session))) controller.loadViewIfNeeded() let views = allSubviews(in: controller.view) let account = try XCTUnwrap(views.compactMap { $0 as? LoginTextField }.first) let password = try XCTUnwrap(views.compactMap { $0 as? PasswordInputField }.first) let agreement = try XCTUnwrap(views.compactMap { $0 as? AgreementRowView }.first) account.text = "13900000001" account.onTextChange?(account.text) password.onTextChange?("mock-password") if agreement.isChecked { agreement.onCheckedChange?() } let button = try XCTUnwrap(views.compactMap { $0 as? UIButton }.first { $0.currentTitle == "登录" }) button.sendActions(for: .touchUpInside) await Task.yield() XCTAssertTrue(session.requests.isEmpty) XCTAssertNil(controller.presentedViewController) } /// 身份选择处于加载状态时仍不允许再次确认。 func testSelectingStoreIdentityWhileLoadingDoesNotConfirm() throws { var confirmed: [AccountSwitchAccount] = [] let controller = AccountSelectionViewController( payload: AccountSelectionPayload(tempToken: "temporary", accounts: [reentryAccount(isStore: true)]), isLoading: true, onCancel: {}, onConfirm: { confirmed.append($0) }) controller.loadViewIfNeeded() let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton } .first { $0.currentTitle == "进入系统" }) button.sendActions(for: .touchUpInside) XCTAssertFalse(button.isEnabled) XCTAssertTrue(confirmed.isEmpty) XCTAssertNil(controller.presentedViewController) } /// 景区身份不受旧门店注销流程影响,不应被多余确认拦截。 func testSelectingScenicIdentityContinuesWithoutDeregistrationPrompt() throws { let account = reentryAccount(isStore: false) var confirmed: [AccountSwitchAccount] = [] let controller = AccountSelectionViewController( payload: AccountSelectionPayload(tempToken: "temporary", accounts: [account]), isLoading: false, onCancel: {}, onConfirm: { confirmed.append($0) } ) controller.loadViewIfNeeded() let button = try XCTUnwrap(allSubviews(in: controller.view).compactMap { $0 as? UIButton } .first { $0.currentTitle == "进入系统" }) button.sendActions(for: .touchUpInside) XCTAssertEqual(confirmed, [account]) XCTAssertNil(controller.presentedViewController) } private func reentryAccount(isStore: Bool) -> AccountSwitchAccount { AccountSwitchAccount( accountType: isStore ? "store_user" : "scenic_user", businessUserId: 101, title: isStore ? "测试门店" : "测试景区", subtitle: "摄影师", phone: "13900000001", realName: "测试用户", avatar: "", scenicName: "测试景区", storeId: isStore ? 25 : nil, storeName: isStore ? "测试门店" : "", scenicId: 10, isCurrent: false ) } private func allSubviews(in view: UIView) -> [UIView] { view.subviews.flatMap { [$0] + allSubviews(in: $0) } } func testNormalizeUsernameCountryCodeRemovesChinaPrefix() { let viewModel = LoginViewModel() viewModel.updateAccount("+86 186 5185 7230") viewModel.normalizeUsernameCountryCodeIfNeeded() XCTAssertEqual(viewModel.normalizedUsername, "18651857230") XCTAssertTrue(viewModel.isValidPhone) } func testIsLoginEnabledRequiresValidPhoneAndNonEmptyPassword() { let viewModel = LoginViewModel() viewModel.updateAccount("18651857230") viewModel.updatePassword(" secret123 ") XCTAssertTrue(viewModel.isLoginEnabled) XCTAssertEqual(viewModel.trimmedPassword, "secret123") } func testValidateForLoginReturnsExpectedErrorsInOrder() { let viewModel = LoginViewModel() viewModel.updateAccount("12345") viewModel.updatePassword("secret123") viewModel.togglePrivacyChecked() XCTAssertEqual(viewModel.validateForLogin(), .invalidPhone) viewModel.updateAccount("18651857230") viewModel.updatePassword(" ") XCTAssertEqual(viewModel.validateForLogin(), .emptyPassword) viewModel.updatePassword("secret123") viewModel.togglePrivacyChecked() XCTAssertEqual(viewModel.validateForLogin(), .privacyUnchecked) } func testLoginWithSingleAccountAutoCompletesSetUser() async throws { let loginJSON = """ { "code": 100000, "msg": "success", "data": { "token": "temp-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 setUserJSON = """ { "code": 100000, "msg": "success", "data": { "token": "business-token-scenic", "scenic_users": [], "store_users": [] } } """.data(using: .utf8)! let session = MockURLSession(responses: [loginJSON, setUserJSON]) let api = AuthAPI(client: APIClient(environment: .testing, session: session)) let viewModel = LoginViewModel() viewModel.updateAccount("18651857230") viewModel.updatePassword("secret123") viewModel.togglePrivacyChecked() let resolution = try await viewModel.login(authAPI: api) guard case .completed(let response, let account) = resolution else { return XCTFail("单账号登录应直接完成") } XCTAssertEqual(response.token, "business-token-scenic") XCTAssertEqual(account.businessUserId, 101) XCTAssertFalse(viewModel.isLoading) XCTAssertNil(viewModel.pendingAccountSelection) XCTAssertEqual(session.requests.map { $0.url?.path }, [ "/api/app/v9/login", "/api/app/v9/set-user", ]) } func testLoginWithMultipleAccountsNeedsSelection() async throws { let loginJSON = """ { "code": 100000, "msg": "success", "data": { "token": "person-temp-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": [ { "account_type": "store_user", "id": 201, "user_id": 201, "store_user_id": 201, "username": "store_admin", "user_name": "store_admin", "real_name": "李四", "phone": "13900139000", "avatar": "", "scenic_id": 10, "scenic_name": "示例景区", "store_id": 20, "store_name": "示例门店", "is_current": false } ] } } """.data(using: .utf8)! let session = MockURLSession(responses: [loginJSON]) let api = AuthAPI(client: APIClient(environment: .testing, session: session)) let viewModel = LoginViewModel() viewModel.updateAccount("18651857230") viewModel.updatePassword("secret123") viewModel.togglePrivacyChecked() 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]) XCTAssertNotNil(viewModel.pendingAccountSelection) XCTAssertEqual(session.requests.count, 1) } func testLoginThrowsWhenNoAvailableAccounts() async { let loginJSON = """ { "code": 100000, "msg": "success", "data": { "token": "person-temp-token", "scenic_users": [], "store_users": [] } } """.data(using: .utf8)! let session = MockURLSession(responses: [loginJSON]) let api = AuthAPI(client: APIClient(environment: .testing, session: session)) let viewModel = LoginViewModel() viewModel.updateAccount("18651857230") viewModel.updatePassword("secret123") viewModel.togglePrivacyChecked() do { _ = try await viewModel.login(authAPI: api) XCTFail("没有可用账号时应抛出错误") } catch let error as LoginFlowError { guard case .noAvailableAccount = error else { return XCTFail("期望 noAvailableAccount,实际为 \(error)") } } catch { XCTFail("意外错误: \(error)") } } }