新增支付与钱包模块,并接入首页路由
引入真实收款与钱包页面替换首页占位入口,通过 RootView 接入 API,并支持银行卡 OSS 上传及二维码保存到相册。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -25,6 +25,10 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "space_settings", title: ""), .destination(.profileSpace))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenicselection", title: ""), .destination(.scenicSelection))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "store", title: ""), .destination(.moreFunctions))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "wallet", title: ""), .destination(.wallet))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_collection", title: ""), .destination(.paymentCollection))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_qr", title: ""), .destination(.paymentCollection))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "payment_code", title: ""), .destination(.paymentCollection))
|
||||
}
|
||||
|
||||
/// 测试已知但未迁移的首页路由会进入安全占位。
|
||||
@ -33,10 +37,6 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
HomeMenuRouter.resolve(uri: "task_management", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "task_management", title: "任务管理"))
|
||||
)
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "payment_qr", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "payment_qr", title: "收款码"))
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
||||
|
||||
@ -14,6 +14,8 @@ final class NavigationRouterTests: XCTestCase {
|
||||
func testPushedRoutesHideTabBarByDefault() {
|
||||
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.paymentCollection).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
|
||||
}
|
||||
|
||||
108
suixinkanTests/PaymentViewModelTests.swift
Normal file
108
suixinkanTests/PaymentViewModelTests.swift
Normal file
@ -0,0 +1,108 @@
|
||||
//
|
||||
// PaymentViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 收款模块测试,覆盖收款码、动态二维码、轮询和记录分组。
|
||||
final class PaymentViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空二维码且不请求接口。
|
||||
func testLoadPayCodeWithoutScenicClearsState() async {
|
||||
let api = PaymentMockAPI()
|
||||
let viewModel = PaymentCollectionViewModel()
|
||||
viewModel.staticPayUrl = "old"
|
||||
|
||||
await viewModel.loadPayCode(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.staticPayUrl.isEmpty)
|
||||
XCTAssertEqual(api.payCodeScenicIds, [])
|
||||
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
|
||||
}
|
||||
|
||||
/// 测试收款码接口加载后生成当前二维码 URL。
|
||||
func testLoadPayCodeUsesScenicIdAndAppliesStaticURL() async {
|
||||
let api = PaymentMockAPI()
|
||||
api.payCodeResponse = PayCodeResponse(staticPayUrl: "https://pay.example.com/static", dynamicPayUrl: "https://pay.example.com/dynamic")
|
||||
let viewModel = PaymentCollectionViewModel()
|
||||
|
||||
await viewModel.loadPayCode(api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.payCodeScenicIds, [9])
|
||||
XCTAssertEqual(viewModel.currentPayUrl, "https://pay.example.com/static")
|
||||
XCTAssertTrue(viewModel.hasStaticPayCode)
|
||||
}
|
||||
|
||||
/// 测试设置金额会校验金额并生成动态收款 URL。
|
||||
func testApplyDynamicAmountBuildsDynamicURL() async {
|
||||
let viewModel = PaymentCollectionViewModel()
|
||||
viewModel.dynamicPayUrl = "https://pay.example.com/dynamic?scenic_id=9"
|
||||
viewModel.amountText = "12.345"
|
||||
viewModel.remarkText = "茶水"
|
||||
|
||||
XCTAssertTrue(viewModel.applyDynamicAmount())
|
||||
XCTAssertTrue(viewModel.currentPayUrl.contains("amount=12.35"))
|
||||
XCTAssertTrue(viewModel.currentPayUrl.contains("remark="))
|
||||
XCTAssertEqual(viewModel.status, .waiting)
|
||||
}
|
||||
|
||||
/// 测试轮询命中新收款记录后进入成功态。
|
||||
func testPollingFindsNewPaymentRecord() async {
|
||||
let api = PaymentMockAPI()
|
||||
api.recordResponses = [
|
||||
PaymentCollectionRecordResponse(list: [
|
||||
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00")
|
||||
]),
|
||||
PaymentCollectionRecordResponse(list: [
|
||||
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-22", createTime: "10:00"),
|
||||
PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01")
|
||||
])
|
||||
]
|
||||
let viewModel = PaymentCollectionViewModel()
|
||||
viewModel.amountText = "12.35"
|
||||
await viewModel.primePaymentRecords(api: api, scenicId: 9)
|
||||
|
||||
await viewModel.pollUntilPaymentDetected(api: api, scenicId: 9, maxAttempts: 1, intervalNanoseconds: 0)
|
||||
|
||||
XCTAssertEqual(viewModel.status, .success(PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.35", createDate: "2026-06-22", createTime: "10:01")))
|
||||
}
|
||||
|
||||
/// 测试后端缺少日汇总时能从列表兜底生成分组。
|
||||
func testRecordGroupsFallbackFromList() {
|
||||
let response = PaymentCollectionRecordResponse(list: [
|
||||
PaymentCollectionRecordItem(orderNumber: "A", userPhone: "138", orderAmount: "8.00", createDate: "2026-06-21", createTime: "10:00"),
|
||||
PaymentCollectionRecordItem(orderNumber: "B", userPhone: "139", orderAmount: "12.00", createDate: "2026-06-21", createTime: "10:01")
|
||||
])
|
||||
|
||||
let groups = PaymentCollectionRecordViewModel.makeGroups(from: response)
|
||||
|
||||
XCTAssertEqual(groups.count, 1)
|
||||
XCTAssertEqual(groups[0].analyse.orderCount, 2)
|
||||
XCTAssertEqual(groups[0].analyse.orderAmountSum, "20")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 收款 API 测试替身。
|
||||
private final class PaymentMockAPI: PaymentServing {
|
||||
var payCodeResponse = PayCodeResponse()
|
||||
var recordResponses: [PaymentCollectionRecordResponse] = []
|
||||
private(set) var payCodeScenicIds: [Int] = []
|
||||
private(set) var recordScenicIds: [Int] = []
|
||||
|
||||
/// 记录景区 ID 并返回收款码响应。
|
||||
func payCode(scenicId: Int) async throws -> PayCodeResponse {
|
||||
payCodeScenicIds.append(scenicId)
|
||||
return payCodeResponse
|
||||
}
|
||||
|
||||
/// 记录景区 ID 并返回下一份收款记录响应。
|
||||
func paymentCollectionRecords(scenicId: Int) async throws -> PaymentCollectionRecordResponse {
|
||||
recordScenicIds.append(scenicId)
|
||||
return recordResponses.isEmpty ? PaymentCollectionRecordResponse() : recordResponses.removeFirst()
|
||||
}
|
||||
}
|
||||
@ -370,6 +370,11 @@ private final class MockOSSUploader: OSSUploadServing {
|
||||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传银行卡照片。
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
}
|
||||
|
||||
/// 生成测试用 JPEG 图片数据。
|
||||
|
||||
@ -43,6 +43,10 @@ final class UploadTests: XCTestCase {
|
||||
OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"task_upload/20260101/9/123456781234123412341234567890AB_video.mp4"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "bank.jpg", scenicId: 9, moduleType: "bank_card", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"bank_card/20260101/9/123456781234123412341234567890AB_bank.jpg"
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试 MIME 类型推断和 URL 拼接规则。
|
||||
|
||||
222
suixinkanTests/WalletViewModelTests.swift
Normal file
222
suixinkanTests/WalletViewModelTests.swift
Normal file
@ -0,0 +1,222 @@
|
||||
//
|
||||
// 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 { "" }
|
||||
}
|
||||
Reference in New Issue
Block a user