373 lines
15 KiB
Swift
373 lines
15 KiB
Swift
//
|
|
// 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)")
|
|
}
|
|
}
|
|
}
|