Add networking layer and wire up v9 login flow with unit tests.

Introduce APIClient/AuthAPI, complete login with multi-account selection, and polish the login card UI with shadow and keyboard dismiss.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 15:33:34 +08:00
parent b7d74905b7
commit 699f1ba7c4
21 changed files with 2387 additions and 18 deletions

View File

@ -0,0 +1,202 @@
//
// LoginViewModelTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class LoginViewModelTests: XCTestCase {
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) = 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",
])
}
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)")
}
}
}