// // 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 uploadProjectImage(data: Data, fileName: String, 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 { "" } }