补充 Auth/Tasks/ScenicPermission/Live 单元测试,并完善 UI Test 登录与会话稳定性。

移除 LoginViewModel 硬编码账号,扩展登录流程测试覆盖;调整 ZLoginSmokeUITests 执行顺序与路由直达逻辑,避免清空 Keychain 影响其它用例。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-26 12:44:10 +08:00
parent aac8458b04
commit d27438b264
17 changed files with 1104 additions and 36 deletions

View File

@ -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)!
)
}
}