补充 Auth/Tasks/ScenicPermission/Live 单元测试,并完善 UI Test 登录与会话稳定性。
移除 LoginViewModel 硬编码账号,扩展登录流程测试覆盖;调整 ZLoginSmokeUITests 执行顺序与路由直达逻辑,避免清空 Keychain 影响其它用例。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -9,7 +9,7 @@ import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验和偏好恢复。
|
||||
/// 登录页 ViewModel 测试,覆盖手机号规范化、表单校验、登录流程和账号选择。
|
||||
final class LoginViewModelTests: XCTestCase {
|
||||
/// 测试 +86 手机号会被规范化为 11 位国内手机号。
|
||||
func testNormalizeUsernameCountryCodeRemovesChinaPrefix() {
|
||||
@ -23,6 +23,47 @@ final class LoginViewModelTests: XCTestCase {
|
||||
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()
|
||||
@ -49,4 +90,319 @@ final class LoginViewModelTests: XCTestCase {
|
||||
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)!
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user