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:
99
suixinkanTests/APIClientTests.swift
Normal file
99
suixinkanTests/APIClientTests.swift
Normal file
@ -0,0 +1,99 @@
|
||||
//
|
||||
// APIClientTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// APIClient 测试,覆盖 Envelope 解包、业务码失败和 token 注入。
|
||||
final class APIClientTests: XCTestCase {
|
||||
func testSendDecodesSuccessEnvelope() async throws {
|
||||
let responseData = try TestJSON.envelope(data: AppConfigResponse(enableRegister: true))
|
||||
let session = MockURLSession(responses: [responseData])
|
||||
let client = APIClient(
|
||||
environment: .testing,
|
||||
session: session,
|
||||
appVersion: "1.0.0",
|
||||
osType: "iOS"
|
||||
)
|
||||
|
||||
let config: AppConfigResponse = try await client.send(
|
||||
APIRequest<AppConfigResponse>(method: .get, path: "/api/app/config")
|
||||
)
|
||||
|
||||
XCTAssertTrue(config.enableRegister)
|
||||
XCTAssertEqual(session.requests.count, 1)
|
||||
XCTAssertEqual(session.requests[0].value(forHTTPHeaderField: "X-APP-VERSION"), "1.0.0")
|
||||
XCTAssertEqual(session.requests[0].value(forHTTPHeaderField: "X-OS-TYPE"), "iOS")
|
||||
}
|
||||
|
||||
func testSendInjectsTokenHeaderWhenProviderBound() async throws {
|
||||
let responseData = try TestJSON.envelope(data: AppConfigResponse(enableRegister: false))
|
||||
let session = MockURLSession(responses: [responseData])
|
||||
let client = APIClient(environment: .testing, session: session)
|
||||
client.bindAuthTokenProvider { "test-token-abc" }
|
||||
|
||||
_ = try await client.send(APIRequest<AppConfigResponse>(method: .get, path: "/api/app/config"))
|
||||
|
||||
XCTAssertEqual(session.requests[0].value(forHTTPHeaderField: "token"), "test-token-abc")
|
||||
}
|
||||
|
||||
func testSendThrowsServerCodeErrorForBusinessFailure() async throws {
|
||||
let responseData = try TestJSON.errorEnvelope(code: 100001, msg: "密码错误")
|
||||
let session = MockURLSession(responses: [responseData])
|
||||
let client = APIClient(environment: .testing, session: session)
|
||||
|
||||
do {
|
||||
let _: AppConfigResponse = try await client.send(
|
||||
APIRequest<AppConfigResponse>(method: .get, path: "/api/app/config")
|
||||
)
|
||||
XCTFail("业务码失败时应抛出错误")
|
||||
} catch let error as APIError {
|
||||
guard case let .serverCode(code, message) = error else {
|
||||
return XCTFail("期望 serverCode,实际为 \(error)")
|
||||
}
|
||||
XCTAssertEqual(code, 100001)
|
||||
XCTAssertEqual(message, "密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
func testSendThrowsHTTPStatusError() async throws {
|
||||
let session = MockURLSession(results: [
|
||||
.success((
|
||||
Data(),
|
||||
HTTPURLResponse(
|
||||
url: URL(string: "https://api-test.zhifly.cn/mock")!,
|
||||
statusCode: 500,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
)),
|
||||
])
|
||||
let client = APIClient(environment: .testing, session: session)
|
||||
|
||||
do {
|
||||
let _: AppConfigResponse = try await client.send(
|
||||
APIRequest<AppConfigResponse>(method: .get, path: "/api/app/config")
|
||||
)
|
||||
XCTFail("HTTP 500 时应抛出错误")
|
||||
} catch let error as APIError {
|
||||
guard case let .httpStatus(statusCode, _) = error else {
|
||||
return XCTFail("期望 httpStatus,实际为 \(error)")
|
||||
}
|
||||
XCTAssertEqual(statusCode, 500)
|
||||
}
|
||||
}
|
||||
|
||||
func testTokenInvalidCodePostsSessionDidExpireNotification() async {
|
||||
let expectation = expectation(forNotification: NotificationName.sessionDidExpire, object: nil)
|
||||
let responseData = try! TestJSON.errorEnvelope(code: 180024, msg: "token 失效")
|
||||
let session = MockURLSession(responses: [responseData])
|
||||
let client = APIClient(environment: .testing, session: session)
|
||||
|
||||
_ = try? await client.send(APIRequest<AppConfigResponse>(method: .get, path: "/api/app/config"))
|
||||
|
||||
await fulfillment(of: [expectation], timeout: 1)
|
||||
}
|
||||
}
|
||||
29
suixinkanTests/APIErrorTests.swift
Normal file
29
suixinkanTests/APIErrorTests.swift
Normal file
@ -0,0 +1,29 @@
|
||||
//
|
||||
// APIErrorTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 网络错误测试,覆盖登录凭证失效判断。
|
||||
final class APIErrorTests: XCTestCase {
|
||||
func testAuthenticationExpiredForUnauthorizedHTTPStatus() {
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(401, "Unauthorized")))
|
||||
XCTAssertTrue(APIError.isAuthenticationExpired(APIError.httpStatus(403, "Forbidden")))
|
||||
}
|
||||
|
||||
func testAuthenticationExpiredForAndroidTokenInvalidCodes() {
|
||||
for code in [180024, 100091, 100090, 100060] {
|
||||
XCTAssertTrue(
|
||||
APIError.isAuthenticationExpired(APIError.serverCode(code, "token 失效")),
|
||||
"code \(code) 应识别为 token 失效"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func testNetworkErrorIsNotAuthenticationExpired() {
|
||||
XCTAssertFalse(APIError.isAuthenticationExpired(APIError.networkFailed("请求超时,请稍后重试")))
|
||||
XCTAssertFalse(APIError.isAuthenticationExpired(APIError.serverCode(100001, "密码错误")))
|
||||
}
|
||||
}
|
||||
129
suixinkanTests/AuthModelsTests.swift
Normal file
129
suixinkanTests/AuthModelsTests.swift
Normal file
@ -0,0 +1,129 @@
|
||||
//
|
||||
// AuthModelsTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// Auth 模块模型测试,覆盖 v9 登录响应解码和账号转换规则。
|
||||
final class AuthModelsTests: XCTestCase {
|
||||
func testV9AuthResponseDecodesMultiAccountJSON() throws {
|
||||
let json = """
|
||||
{
|
||||
"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 envelope = try JSONDecoder().decode(APIEnvelope<V9AuthResponse>.self, from: json)
|
||||
let response = try XCTUnwrap(envelope.data)
|
||||
|
||||
XCTAssertEqual(response.token, "person-temp-token")
|
||||
XCTAssertEqual(response.accounts.map(\.businessUserId), [101, 201])
|
||||
}
|
||||
|
||||
func testV9AuthResponseDecodesMissingAccountListsAsEmpty() throws {
|
||||
let json = """
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "only-token"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let envelope = try JSONDecoder().decode(APIEnvelope<V9AuthResponse>.self, from: json)
|
||||
let response = try XCTUnwrap(envelope.data)
|
||||
|
||||
XCTAssertEqual(response.token, "only-token")
|
||||
XCTAssertTrue(response.scenicUsers.isEmpty)
|
||||
XCTAssertTrue(response.storeUsers.isEmpty)
|
||||
}
|
||||
|
||||
func testV9ScenicUserBusinessUserIdPrefersSsUserId() throws {
|
||||
let json = """
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"id": "1",
|
||||
"user_id": 2,
|
||||
"scenic_user_id": 3,
|
||||
"ss_user_id": 101,
|
||||
"username": "scenic_admin",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"is_current": false
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let user = try JSONDecoder().decode(V9ScenicUser.self, from: json)
|
||||
|
||||
XCTAssertEqual(user.businessUserId, 101)
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(ssUserId: 101))
|
||||
}
|
||||
|
||||
func testV9StoreUserBusinessUserIdPrefersStoreUserId() throws {
|
||||
let json = """
|
||||
{
|
||||
"account_type": "store_user",
|
||||
"id": 1,
|
||||
"user_id": 2,
|
||||
"store_user_id": 201,
|
||||
"username": "store_admin",
|
||||
"user_name": "store_admin",
|
||||
"real_name": "张三",
|
||||
"phone": "13800138000",
|
||||
"avatar": "",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "示例景区",
|
||||
"store_id": 20,
|
||||
"store_name": "示例门店",
|
||||
"is_current": false
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let user = try JSONDecoder().decode(V9StoreUser.self, from: json)
|
||||
|
||||
XCTAssertEqual(user.businessUserId, 201)
|
||||
XCTAssertEqual(user.toAccountSwitchAccount().toSetUserRequest(), SetUserRequest(storeUserId: 201))
|
||||
}
|
||||
}
|
||||
202
suixinkanTests/LoginViewModelTests.swift
Normal file
202
suixinkanTests/LoginViewModelTests.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
63
suixinkanTests/MockURLSession.swift
Normal file
63
suixinkanTests/MockURLSession.swift
Normal file
@ -0,0 +1,63 @@
|
||||
//
|
||||
// MockURLSession.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@testable import suixinkan
|
||||
|
||||
/// 按顺序返回预设响应的 URLSession 测试替身。
|
||||
final class MockURLSession: URLSessionProtocol {
|
||||
private(set) var requests: [URLRequest] = []
|
||||
private var responses: [Result<(Data, URLResponse), Error>]
|
||||
private var responseIndex = 0
|
||||
|
||||
init(responses: [Data], statusCode: Int = 200) {
|
||||
self.responses = responses.map { data in
|
||||
.success((
|
||||
data,
|
||||
HTTPURLResponse(
|
||||
url: URL(string: "https://api-test.zhifly.cn/mock")!,
|
||||
statusCode: statusCode,
|
||||
httpVersion: nil,
|
||||
headerFields: nil
|
||||
)!
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
init(results: [Result<(Data, URLResponse), Error>]) {
|
||||
self.responses = results
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
guard responseIndex < responses.count else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
defer { responseIndex += 1 }
|
||||
return try responses[responseIndex].get()
|
||||
}
|
||||
}
|
||||
|
||||
enum TestJSON {
|
||||
static func envelope<T: Encodable>(data: T) throws -> Data {
|
||||
let payload = try JSONEncoder().encode(data)
|
||||
let payloadObject = try JSONSerialization.jsonObject(with: payload)
|
||||
let envelope: [String: Any] = [
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": payloadObject,
|
||||
]
|
||||
return try JSONSerialization.data(withJSONObject: envelope)
|
||||
}
|
||||
|
||||
static func errorEnvelope(code: Int, msg: String) throws -> Data {
|
||||
let envelope: [String: Any] = [
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
"data": NSNull(),
|
||||
]
|
||||
return try JSONSerialization.data(withJSONObject: envelope)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user