Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library. Co-authored-by: Cursor <cursoragent@cursor.com>
223 lines
10 KiB
Swift
223 lines
10 KiB
Swift
//
|
||
// WalletViewModelTests.swift
|
||
// suixinkanTests
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import XCTest
|
||
@testable import suixinkan
|
||
|
||
@MainActor
|
||
/// 钱包模块测试,覆盖首页、提现资格、银行卡上传和积分兑换。
|
||
final class WalletViewModelTests: XCTestCase {
|
||
/// 测试钱包首页会加载汇总和收益明细。
|
||
func testWalletInitialLoadFetchesSummaryAndEarnings() async {
|
||
let api = WalletMockAPI()
|
||
api.summary = WalletSummaryResponse(amountTotal: "100", amountCurrentBalance: "80", amountWithdrawable: "60")
|
||
api.earningResponses = [WalletEarningDetailResponse(total: 1, list: [WalletEarningDetailGroup(date: "2026-06-22")])]
|
||
let viewModel = WalletViewModel()
|
||
|
||
await viewModel.loadInitial(api: api, staffId: 7)
|
||
|
||
XCTAssertEqual(viewModel.withdrawableText, "¥ 60")
|
||
XCTAssertEqual(api.pointOverviewStaffIds, [7])
|
||
XCTAssertEqual(api.earningQueries.count, 1)
|
||
XCTAssertEqual(viewModel.earningsGroups.map(\.date), ["2026-06-22"])
|
||
}
|
||
|
||
/// 测试切换到提现记录 Tab 会请求提现分页。
|
||
func testSelectWithdrawTabLoadsWithdrawRecords() async {
|
||
let api = WalletMockAPI()
|
||
let viewModel = WalletViewModel()
|
||
|
||
await viewModel.selectTab(.withdraws, api: api)
|
||
|
||
XCTAssertEqual(api.withdrawListPages, [1])
|
||
}
|
||
|
||
/// 测试提现资格流根据实名认证和银行卡状态进入正确页面。
|
||
func testWithdrawDecisionRoutesByRealNameAndBankCardState() async throws {
|
||
let profile = WalletRealNameMockAPI()
|
||
let wallet = WalletMockAPI()
|
||
let viewModel = WalletViewModel()
|
||
|
||
profile.response = RealNameInfoResponse(realNameInfo: nil)
|
||
var decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
|
||
XCTAssertEqual(decision, .route(.realNameAuth))
|
||
|
||
profile.response = try Self.realNameInfo(status: 2)
|
||
wallet.bankCardResponse = BankCardInfoResponse(bankCard: nil)
|
||
decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
|
||
XCTAssertEqual(decision, .route(.bankCardSettings))
|
||
|
||
wallet.bankCardResponse = try BankCardInfoResponse(bankCard: Self.bankCard(status: 2))
|
||
decision = await viewModel.resolveWithdrawDecision(profileAPI: profile, walletAPI: wallet)
|
||
XCTAssertEqual(decision, .route(.withdrawApply))
|
||
}
|
||
|
||
/// 测试提现申请提交会校验金额并传递规范化金额。
|
||
func testWithdrawApplySubmitsNormalizedAmount() async throws {
|
||
let api = WalletMockAPI()
|
||
api.withdrawInfoResponse = try Self.withdrawInfo(withdrawable: "100")
|
||
let viewModel = WithdrawApplyViewModel()
|
||
await viewModel.load(api: api)
|
||
viewModel.amountText = "12.345"
|
||
viewModel.smsCode = "8899"
|
||
|
||
let success = await viewModel.submit(api: api)
|
||
|
||
XCTAssertTrue(success)
|
||
XCTAssertEqual(api.withdrawApplyBodies.map(\.amount), ["12.35"])
|
||
XCTAssertEqual(api.withdrawApplyBodies.map(\.smsCode), ["8899"])
|
||
}
|
||
|
||
/// 测试银行卡设置会先上传正反面照片,再提交最终 URL。
|
||
func testWithdrawalSettingsUploadsImagesBeforeSubmit() async {
|
||
let api = WalletMockAPI()
|
||
let uploader = WalletUploadMock()
|
||
let viewModel = WithdrawalSettingsViewModel()
|
||
viewModel.realName = "张三"
|
||
viewModel.cardNumber = "622200"
|
||
viewModel.bankName = "中国银行"
|
||
viewModel.branchName = "杭州支行"
|
||
viewModel.provinceCode = "330000"
|
||
viewModel.cityCode = "330100"
|
||
viewModel.smsCode = "1234"
|
||
viewModel.frontImageData = Data([1])
|
||
viewModel.backImageData = Data([2])
|
||
|
||
let success = await viewModel.submit(api: api, uploader: uploader, scenicId: 9)
|
||
|
||
XCTAssertTrue(success)
|
||
XCTAssertEqual(uploader.bankUploads.count, 2)
|
||
XCTAssertEqual(api.updateBankRequests.first?.frontUrl, "https://cdn.example.com/bank_card_front.jpg")
|
||
XCTAssertEqual(api.updateBankRequests.first?.backUrl, "https://cdn.example.com/bank_card_back.jpg")
|
||
}
|
||
|
||
/// 测试积分兑换不能超过可兑换积分,成功后会刷新列表。
|
||
func testPointsRedemptionValidationAndSubmit() async {
|
||
let api = WalletMockAPI()
|
||
api.pointOverviewResponse = PointOverviewResponse(withdrawnPoints: 100)
|
||
let viewModel = PointsRedemptionViewModel()
|
||
await viewModel.load(api: api, staffId: 7)
|
||
viewModel.pointsText = "120"
|
||
|
||
var success = await viewModel.submit(api: api, staffId: 7)
|
||
XCTAssertFalse(success)
|
||
XCTAssertEqual(viewModel.errorMessage, "兑换积分不能超过可兑换积分")
|
||
|
||
viewModel.pointsText = "80"
|
||
success = await viewModel.submit(api: api, staffId: 7)
|
||
XCTAssertTrue(success)
|
||
XCTAssertEqual(api.pointApplyBodies.map(\.points), [80])
|
||
}
|
||
|
||
/// 从 JSON 创建实名认证响应。
|
||
private static func realNameInfo(status: Int) throws -> RealNameInfoResponse {
|
||
let data = """
|
||
{"real_name_info":{"real_name":"张三","id_card_no":"11010519491231002X","audit_status":\(status),"is_long_valid":1}}
|
||
""".data(using: .utf8)!
|
||
return try JSONDecoder().decode(RealNameInfoResponse.self, from: data)
|
||
}
|
||
|
||
/// 从 JSON 创建银行卡信息。
|
||
private static func bankCard(status: Int) throws -> WalletBankCardInfo {
|
||
let data = """
|
||
{"real_name":"张三","bank_name":"中国银行","branch_name":"杭州支行","card_number":"622200","audit_status":\(status),"audit_status_label":"通过"}
|
||
""".data(using: .utf8)!
|
||
return try JSONDecoder().decode(WalletBankCardInfo.self, from: data)
|
||
}
|
||
|
||
/// 从 JSON 创建提现信息。
|
||
fileprivate static func withdrawInfo(withdrawable: String) throws -> WithdrawInfoResponse {
|
||
let data = """
|
||
{"amount_withdrawable":"\(withdrawable)","min_withdraw_amount":"1","max_single_withdraw_amount":"1000","max_daily_withdraw_amount":"1000","user_phone":"13800000000","bank_card":{"real_name":"张三","bank_name":"中国银行","card_number":"622200"},"withdraw_info":[]}
|
||
""".data(using: .utf8)!
|
||
return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data)
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
/// 钱包 API 测试替身。
|
||
private final class WalletMockAPI: WalletServing {
|
||
var summary = WalletSummaryResponse()
|
||
var earningResponses: [WalletEarningDetailResponse] = []
|
||
var withdrawResponses: [WalletWithdrawListResponse] = []
|
||
var withdrawInfoResponse: WithdrawInfoResponse?
|
||
var bankCardResponse = BankCardInfoResponse(bankCard: nil)
|
||
var pointOverviewResponse = PointOverviewResponse()
|
||
private(set) var earningQueries: [(start: String, end: String, page: Int)] = []
|
||
private(set) var withdrawListPages: [Int] = []
|
||
private(set) var withdrawApplyBodies: [(amount: String, smsCode: String)] = []
|
||
private(set) var updateBankRequests: [UpdateBankInfoRequest] = []
|
||
private(set) var pointOverviewStaffIds: [Int] = []
|
||
private(set) var pointApplyBodies: [(points: Int, remark: String)] = []
|
||
|
||
func walletSummary(type: Int) async throws -> WalletSummaryResponse { summary }
|
||
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse {
|
||
earningQueries.append((startDate, endDate, page))
|
||
return earningResponses.isEmpty ? WalletEarningDetailResponse() : earningResponses.removeFirst()
|
||
}
|
||
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
|
||
withdrawListPages.append(page)
|
||
return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst()
|
||
}
|
||
func withdrawInfo() async throws -> WithdrawInfoResponse {
|
||
if let withdrawInfoResponse { return withdrawInfoResponse }
|
||
return try WalletViewModelTests.withdrawInfo(withdrawable: "0")
|
||
}
|
||
func withdrawSendSms() async throws {}
|
||
func withdrawApply(amount: String, smsCode: String) async throws {
|
||
withdrawApplyBodies.append((amount, smsCode))
|
||
}
|
||
func bankCardInfo() async throws -> BankCardInfoResponse { bankCardResponse }
|
||
func bankList() async throws -> BankListResponse { BankListResponse(banks: ["中国银行"]) }
|
||
func areas() async throws -> [AreaNode] { [] }
|
||
func bankCardVerifyCode() async throws {}
|
||
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {
|
||
updateBankRequests.append(request)
|
||
}
|
||
func pointOverview(staffId: Int) async throws -> PointOverviewResponse {
|
||
pointOverviewStaffIds.append(staffId)
|
||
return pointOverviewResponse
|
||
}
|
||
func pointWithdrawApply(points: Int, remark: String) async throws {
|
||
pointApplyBodies.append((points, remark))
|
||
}
|
||
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
|
||
PointWithdrawListResponse()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
/// 钱包实名认证测试替身。
|
||
private final class WalletRealNameMockAPI: WalletRealNameServing {
|
||
var response = RealNameInfoResponse(realNameInfo: nil)
|
||
|
||
/// 返回预设实名认证响应。
|
||
func realNameInfo() async throws -> RealNameInfoResponse {
|
||
response
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
/// 钱包上传测试替身。
|
||
private final class WalletUploadMock: OSSUploadServing {
|
||
private(set) var bankUploads: [String] = []
|
||
|
||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||
bankUploads.append(fileName)
|
||
onProgress(100)
|
||
return "https://cdn.example.com/\(fileName)"
|
||
}
|
||
|
||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||
}
|