Implement profile tab with Android-aligned flows and OSS upload.
Add personal info page, account switch, real-name auth, withdrawal settings, session cache extensions, AlibabaCloudOSS SPM, and unit tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
77
suixinkanTests/AccountSwitchViewModelTests.swift
Normal file
77
suixinkanTests/AccountSwitchViewModelTests.swift
Normal file
@ -0,0 +1,77 @@
|
||||
//
|
||||
// AccountSwitchViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 账号切换 ViewModel 测试。
|
||||
final class AccountSwitchViewModelTests: XCTestCase {
|
||||
func testLoadFiltersInvalidAccounts() async throws {
|
||||
let json = """
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "token",
|
||||
"scenic_users": [
|
||||
{
|
||||
"account_type": "scenic_user",
|
||||
"id": 1,
|
||||
"user_id": 1,
|
||||
"scenic_user_id": 1,
|
||||
"ss_user_id": 9001,
|
||||
"username": "u1",
|
||||
"real_name": "张三",
|
||||
"nickname": "张三",
|
||||
"phone": "13800138000",
|
||||
"scenic_id": 10,
|
||||
"scenic_name": "景区A",
|
||||
"is_current": true
|
||||
}
|
||||
],
|
||||
"store_users": []
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let session = MockURLSession(responses: [json])
|
||||
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = AccountSwitchViewModel()
|
||||
|
||||
try await viewModel.load(api: api, force: true)
|
||||
|
||||
XCTAssertEqual(viewModel.accounts.count, 1)
|
||||
XCTAssertEqual(viewModel.selectedAccount?.businessUserId, 9001)
|
||||
}
|
||||
|
||||
func testSwitchAccountThrowsWhenBusinessUserIdInvalid() async {
|
||||
let viewModel = AccountSwitchViewModel()
|
||||
let account = AccountSwitchAccount(
|
||||
accountType: V9ScenicUser.accountTypeValue,
|
||||
businessUserId: 0,
|
||||
title: "景区A",
|
||||
subtitle: "张三",
|
||||
phone: "13800138000",
|
||||
avatar: "",
|
||||
scenicName: "景区A",
|
||||
storeId: nil,
|
||||
storeName: "",
|
||||
scenicId: 10,
|
||||
isCurrent: false
|
||||
)
|
||||
let authAPI = AuthAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [])))
|
||||
|
||||
do {
|
||||
_ = try await viewModel.switchAccount(account, api: authAPI)
|
||||
XCTFail("无效账号应失败")
|
||||
} catch {
|
||||
guard let switchError = error as? AccountSwitchError else {
|
||||
return XCTFail("期望 AccountSwitchError,实际为 \(error)")
|
||||
}
|
||||
XCTAssertEqual(switchError, .invalidAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -93,10 +93,11 @@ final class LoginViewModelTests: XCTestCase {
|
||||
|
||||
let resolution = try await viewModel.login(authAPI: api)
|
||||
|
||||
guard case .completed(let response) = resolution else {
|
||||
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 }, [
|
||||
|
||||
65
suixinkanTests/ProfileModelsTests.swift
Normal file
65
suixinkanTests/ProfileModelsTests.swift
Normal file
@ -0,0 +1,65 @@
|
||||
//
|
||||
// ProfileModelsTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// Profile 模型解码测试。
|
||||
final class ProfileModelsTests: XCTestCase {
|
||||
func testUserInfoResponseDecodesSnakeCaseFields() throws {
|
||||
let json = """
|
||||
{
|
||||
"avatar": "https://cdn.example.com/a.jpg",
|
||||
"real_name": "张三",
|
||||
"phone": "13800138000",
|
||||
"nickname": "小张",
|
||||
"role_name": "摄影师",
|
||||
"status": 1,
|
||||
"status_name": "正常"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let info = try JSONDecoder().decode(UserInfoResponse.self, from: json)
|
||||
|
||||
XCTAssertEqual(info.realName, "张三")
|
||||
XCTAssertEqual(info.nickname, "小张")
|
||||
XCTAssertEqual(info.roleName, "摄影师")
|
||||
XCTAssertEqual(info.statusName, "正常")
|
||||
}
|
||||
|
||||
func testBankCardInfoDecodesAuditStatusAsString() throws {
|
||||
let json = """
|
||||
{
|
||||
"real_name": "张三",
|
||||
"bank_name": "中国银行",
|
||||
"branch_name": "南京支行",
|
||||
"card_number": "6222021234567890",
|
||||
"audit_status": "2",
|
||||
"audit_status_label": "审核通过",
|
||||
"reject_reason": ""
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let info = try JSONDecoder().decode(BankCardInfo.self, from: json)
|
||||
|
||||
XCTAssertEqual(info.auditStatus, 2)
|
||||
XCTAssertEqual(info.bankName, "中国银行")
|
||||
}
|
||||
|
||||
func testRealNameInfoVerifiedWhenAuditStatusIsTwo() throws {
|
||||
let json = """
|
||||
{
|
||||
"real_name": "张三",
|
||||
"id_card_no": "320102199001011234",
|
||||
"audit_status": 2,
|
||||
"is_long_valid": 1
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let info = try JSONDecoder().decode(RealNameInfo.self, from: json)
|
||||
|
||||
XCTAssertTrue(info.verified)
|
||||
}
|
||||
}
|
||||
52
suixinkanTests/ProfileViewModelTests.swift
Normal file
52
suixinkanTests/ProfileViewModelTests.swift
Normal file
@ -0,0 +1,52 @@
|
||||
//
|
||||
// ProfileViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 个人信息页 ViewModel 测试。
|
||||
final class ProfileViewModelTests: XCTestCase {
|
||||
func testDisplayNicknameUsesFallbackWhenEmpty() {
|
||||
let viewModel = ProfileViewModel()
|
||||
XCTAssertEqual(viewModel.displayNickname, "未设置昵称")
|
||||
}
|
||||
|
||||
func testShowPhotographerFieldsWhenRoleCodeIsPhotographer() {
|
||||
AppStore.shared.roleCode = AppRoleCode.photographer.rawValue
|
||||
let viewModel = ProfileViewModel()
|
||||
XCTAssertTrue(viewModel.showPhotographerFields)
|
||||
AppStore.shared.roleCode = ""
|
||||
}
|
||||
|
||||
func testMaskCardNumberShowsLastFourDigits() {
|
||||
XCTAssertEqual(
|
||||
ProfileViewModel.maskCardNumber("6222021234567890"),
|
||||
"**** **** **** 7890"
|
||||
)
|
||||
}
|
||||
|
||||
func testUpdatePasswordRejectsShortPassword() async {
|
||||
let session = MockURLSession(responses: [])
|
||||
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
|
||||
let viewModel = ProfileViewModel()
|
||||
|
||||
do {
|
||||
try await viewModel.updatePassword("12345", api: api)
|
||||
XCTFail("短密码应抛出校验错误")
|
||||
} catch {
|
||||
guard let validationError = error as? ProfileValidationError else {
|
||||
return XCTFail("期望 ProfileValidationError,实际为 \(error)")
|
||||
}
|
||||
XCTAssertEqual(validationError, .shortPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func testBeginEditingPrefillsNickname() {
|
||||
let viewModel = ProfileViewModel()
|
||||
viewModel.beginEditing()
|
||||
XCTAssertTrue(viewModel.isEditingProfile)
|
||||
}
|
||||
}
|
||||
30
suixinkanTests/RealNameAuthViewModelTests.swift
Normal file
30
suixinkanTests/RealNameAuthViewModelTests.swift
Normal file
@ -0,0 +1,30 @@
|
||||
//
|
||||
// RealNameAuthViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 实名认证 ViewModel 测试。
|
||||
final class RealNameAuthViewModelTests: XCTestCase {
|
||||
func testValidationRequiresSmsCodeWhenNotVerified() {
|
||||
let viewModel = RealNameAuthViewModel()
|
||||
viewModel.updateRealName("张三")
|
||||
viewModel.updateIdCardNo("11010519491231002X")
|
||||
|
||||
XCTAssertEqual(viewModel.validationMessage, "请输入短信验证码")
|
||||
}
|
||||
|
||||
func testIsValidMainlandIDCardNumberChecksChecksum() {
|
||||
XCTAssertTrue(RealNameAuthViewModel.isValidMainlandIDCardNumber("11010519491231002X"))
|
||||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
||||
}
|
||||
|
||||
func testNormalizedIDCardNumberUppercasesSuffix() {
|
||||
XCTAssertEqual(
|
||||
RealNameAuthViewModel.normalizedIDCardNumber("11010519491231002x"),
|
||||
"11010519491231002X"
|
||||
)
|
||||
}
|
||||
}
|
||||
58
suixinkanTests/WithdrawalSettingsViewModelTests.swift
Normal file
58
suixinkanTests/WithdrawalSettingsViewModelTests.swift
Normal file
@ -0,0 +1,58 @@
|
||||
//
|
||||
// WithdrawalSettingsViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 提现设置 ViewModel 测试。
|
||||
final class WithdrawalSettingsViewModelTests: XCTestCase {
|
||||
func testCanGoNextStepRequiresImagesAndAgreement() {
|
||||
let viewModel = WithdrawalSettingsViewModel()
|
||||
viewModel.updateHolderName("张三")
|
||||
viewModel.updateBankNo("6222021234567890")
|
||||
viewModel.updateAgree(true)
|
||||
|
||||
XCTAssertFalse(viewModel.canGoNextStep)
|
||||
|
||||
try? viewModel.prepareBankCardImage(
|
||||
data: UIImage(systemName: "photo")!.pngData()!,
|
||||
side: .front
|
||||
)
|
||||
try? viewModel.prepareBankCardImage(
|
||||
data: UIImage(systemName: "photo")!.pngData()!,
|
||||
side: .back
|
||||
)
|
||||
|
||||
XCTAssertTrue(viewModel.canGoNextStep)
|
||||
}
|
||||
|
||||
func testCanSubmitRequiresStepTwoFields() throws {
|
||||
let viewModel = WithdrawalSettingsViewModel()
|
||||
viewModel.updateHolderName("张三")
|
||||
viewModel.updateBankNo("6222021234567890")
|
||||
viewModel.updateAgree(true)
|
||||
try viewModel.prepareBankCardImage(
|
||||
data: XCTUnwrap(UIImage(systemName: "photo")?.pngData()),
|
||||
side: .front
|
||||
)
|
||||
try viewModel.prepareBankCardImage(
|
||||
data: XCTUnwrap(UIImage(systemName: "photo")?.pngData()),
|
||||
side: .back
|
||||
)
|
||||
viewModel.goNextStep()
|
||||
|
||||
viewModel.updateBankName("中国银行")
|
||||
viewModel.updateBranchName("南京支行")
|
||||
viewModel.updateVerificationCode("123456")
|
||||
|
||||
XCTAssertFalse(viewModel.canSubmit)
|
||||
|
||||
viewModel.selectProvince(AreasResponse(id: 1, code: "32", name: "江苏", children: []))
|
||||
viewModel.selectCity(AreasResponse(id: 2, code: "3201", name: "南京", children: []))
|
||||
|
||||
XCTAssertTrue(viewModel.canSubmit)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user