Add Payment and Wallet modules with home routing integration.
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>
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