完善实名认证、钱包与打卡点模块 UI 与业务逻辑。
对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -62,4 +62,54 @@ final class ProfileModelsTests: XCTestCase {
|
||||
|
||||
XCTAssertTrue(info.verified)
|
||||
}
|
||||
|
||||
func testRealNameInfoDecodesCompleteAuditDetails() throws {
|
||||
let json = """
|
||||
{
|
||||
"real_name": "张三",
|
||||
"id_card_no": "11010519491231002X",
|
||||
"audit_status": "3",
|
||||
"audit_status_text": "审核驳回",
|
||||
"reject_reason": "国徽面照片模糊",
|
||||
"start_date": "2020-01-01",
|
||||
"end_date": "2030-01-01",
|
||||
"is_long_valid": "0",
|
||||
"front_url": "https://cdn.example.com/front.jpg",
|
||||
"back_url": "https://cdn.example.com/back.jpg",
|
||||
"auditor_id": "8",
|
||||
"auditor": { "id": "8", "name": "审核员" },
|
||||
"audit_at": "2026-07-13 12:00:00"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let info = try JSONDecoder().decode(RealNameInfo.self, from: json)
|
||||
|
||||
XCTAssertEqual(info.auditStatus, 3)
|
||||
XCTAssertEqual(info.rejectReason, "国徽面照片模糊")
|
||||
XCTAssertEqual(info.frontUrl, "https://cdn.example.com/front.jpg")
|
||||
XCTAssertEqual(info.backUrl, "https://cdn.example.com/back.jpg")
|
||||
XCTAssertEqual(info.auditorId, 8)
|
||||
XCTAssertEqual(info.auditor?.name, "审核员")
|
||||
XCTAssertEqual(info.auditAt, "2026-07-13 12:00:00")
|
||||
}
|
||||
|
||||
func testRealNameInfoRemainsCompatibleWhenOptionalAuditFieldsAreMissing() throws {
|
||||
let json = """
|
||||
{
|
||||
"real_name": "李四",
|
||||
"id_card_no": "11010519491231002X",
|
||||
"audit_status": 1,
|
||||
"is_long_valid": 1
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let info = try JSONDecoder().decode(RealNameInfo.self, from: json)
|
||||
|
||||
XCTAssertEqual(info.auditStatus, 1)
|
||||
XCTAssertTrue(info.isLongValid)
|
||||
XCTAssertNil(info.rejectReason)
|
||||
XCTAssertNil(info.frontUrl)
|
||||
XCTAssertNil(info.auditor)
|
||||
XCTAssertNil(info.auditAt)
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,4 +82,22 @@ final class PunchPointModelsTests: XCTestCase {
|
||||
XCTAssertTrue(image.isUploaded)
|
||||
XCTAssertFalse(image.isUploading)
|
||||
}
|
||||
|
||||
func testOperatingAndAuditStatusPresentationMatchAndroid() {
|
||||
XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(1).title, "营运中")
|
||||
XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(2).title, "已暂停")
|
||||
XCTAssertEqual(PunchPointDisplayFormatter.operatingStatus(4).title, "未上线")
|
||||
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(1).title, "已通过")
|
||||
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(2).textColor, 0x22C55E)
|
||||
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(3).title, "待审核")
|
||||
XCTAssertEqual(PunchPointDisplayFormatter.auditStatus(4).title, "已驳回")
|
||||
}
|
||||
|
||||
func testAllAndroidFiltersKeepExactStatusValuesAndTitles() {
|
||||
XCTAssertEqual(PunchPointFilterType.allCases.map(\.apiStatus), [0, 1, 2, 3, 4])
|
||||
XCTAssertEqual(
|
||||
PunchPointFilterType.allCases.map(\.title),
|
||||
["全部", "运营中", "已暂停", "未审核", "审核未通过"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -39,8 +39,10 @@ final class PunchPointViewModelTests: XCTestCase {
|
||||
let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 })
|
||||
var messages: [String] = []
|
||||
var qrcode: String?
|
||||
var removalID: Int64?
|
||||
viewModel.onShowMessage = { messages.append($0) }
|
||||
viewModel.onShowQRCode = { qrcode = $0 }
|
||||
viewModel.onBeginDeleteAnimation = { removalID = $0 }
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
viewModel.showQRCode(for: viewModel.items[1])
|
||||
@ -50,6 +52,9 @@ final class PunchPointViewModelTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(messages, ["未审核通过不可查看二维码", "暂无二维码"])
|
||||
XCTAssertEqual(qrcode, "https://cdn/qr.png")
|
||||
XCTAssertEqual(removalID, 1)
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
|
||||
viewModel.completeDeleteAnimation(id: 1)
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [2])
|
||||
XCTAssertEqual(api.deletedIds, [1])
|
||||
}
|
||||
@ -140,6 +145,59 @@ final class PunchPointViewModelTests: XCTestCase {
|
||||
XCTAssertTrue(api.addRequests.isEmpty)
|
||||
}
|
||||
|
||||
func testImageLimitAcceptsNineAndRejectsFurtherSelection() async {
|
||||
let uploader = MockPunchPointUploader()
|
||||
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 })
|
||||
var messages: [String] = []
|
||||
viewModel.onShowMessage = { messages.append($0) }
|
||||
let images = (0 ..< 10).map {
|
||||
PunchPointImageState(data: Data([UInt8($0)]), fileName: "\($0).jpg")
|
||||
}
|
||||
|
||||
await viewModel.addLocalImages(images, uploader: uploader)
|
||||
await viewModel.addLocalImages(
|
||||
[PunchPointImageState(data: Data([99]), fileName: "extra.jpg")],
|
||||
uploader: uploader
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.images.count, 9)
|
||||
XCTAssertEqual(uploader.uploads.count, 9)
|
||||
XCTAssertEqual(messages.last, "最多只能选择9张图片")
|
||||
}
|
||||
|
||||
func testFailedImageCanRetryAndBecomeUploaded() async {
|
||||
let uploader = SequencedPunchPointUploader()
|
||||
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 })
|
||||
|
||||
await viewModel.addLocalImages(
|
||||
[PunchPointImageState(data: Data([1]), fileName: "retry.jpg")],
|
||||
uploader: uploader
|
||||
)
|
||||
XCTAssertNotNil(viewModel.images.first?.errorMessage)
|
||||
|
||||
await viewModel.retryUpload(at: 0, uploader: uploader)
|
||||
|
||||
XCTAssertNil(viewModel.images.first?.errorMessage)
|
||||
XCTAssertEqual(viewModel.images.first?.uploadedURL, "https://cdn/retry.jpg")
|
||||
XCTAssertEqual(uploader.callCount, 2)
|
||||
}
|
||||
|
||||
func testAutoLocationFillsCoordinateAndAddress() async {
|
||||
let location = MockPunchPointLocationProvider(
|
||||
snapshot: HomeLocationSnapshot(latitude: 31.2, longitude: 121.5, address: "当前位置地址")
|
||||
)
|
||||
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location)
|
||||
var changedCoordinate: CLLocationCoordinate2D?
|
||||
viewModel.onCoordinateChange = { changedCoordinate = $0 }
|
||||
|
||||
await viewModel.autoLocation()
|
||||
|
||||
XCTAssertEqual(viewModel.coordinate?.latitude, 31.2)
|
||||
XCTAssertEqual(viewModel.coordinate?.longitude, 121.5)
|
||||
XCTAssertEqual(viewModel.address, "当前位置地址")
|
||||
XCTAssertEqual(changedCoordinate?.latitude, 31.2)
|
||||
}
|
||||
|
||||
private func item(id: Int64, status: Int = 1, mpQrcode: String? = nil) -> PunchPointItem {
|
||||
PunchPointItem(
|
||||
id: id,
|
||||
@ -233,6 +291,25 @@ private final class SlowPunchPointUploader: PunchPointImageUploading {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SequencedPunchPointUploader: PunchPointImageUploading {
|
||||
var callCount = 0
|
||||
|
||||
func uploadPunchPointImage(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
callCount += 1
|
||||
if callCount == 1 {
|
||||
throw URLError(.cannotConnectToHost)
|
||||
}
|
||||
onProgress(100)
|
||||
return "https://cdn/retry.jpg"
|
||||
}
|
||||
}
|
||||
|
||||
private final class MockPunchPointLocationProvider: LocationProviding, @unchecked Sendable {
|
||||
let snapshot: HomeLocationSnapshot
|
||||
let reverseAddress: String
|
||||
|
||||
66
suixinkanTests/RealNameAuthAPITests.swift
Normal file
66
suixinkanTests/RealNameAuthAPITests.swift
Normal file
@ -0,0 +1,66 @@
|
||||
//
|
||||
// RealNameAuthAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 实名认证 API 契约测试。
|
||||
@MainActor
|
||||
final class RealNameAuthAPITests: XCTestCase {
|
||||
func testInfoUsesAndroidGETPath() async throws {
|
||||
let response = Data(#"{"code":100000,"msg":"success","data":{"real_name_info":null}}"#.utf8)
|
||||
let session = MockURLSession(responses: [response])
|
||||
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
_ = try await api.realNameInfo()
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/info")
|
||||
}
|
||||
|
||||
func testSMSCodeUsesPOSTWithoutBody() async throws {
|
||||
let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8)
|
||||
let session = MockURLSession(responses: [response])
|
||||
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
try await api.realNameSmsVerifyCode()
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/sms-verify-code")
|
||||
XCTAssertNil(request.httpBody)
|
||||
}
|
||||
|
||||
func testSubmitUsesAndroidJSONFields() async throws {
|
||||
let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8)
|
||||
let session = MockURLSession(responses: [response])
|
||||
let api = ProfileAPI(client: APIClient(environment: .testing, session: session))
|
||||
let payload = RealNameAuthRequest(
|
||||
realName: "张三",
|
||||
idCardNo: "11010519491231002X",
|
||||
smsVerifyCode: "123456",
|
||||
startDate: "2026-07-13",
|
||||
endDate: "",
|
||||
isLongValid: 1,
|
||||
frontUrl: "front",
|
||||
backUrl: "back"
|
||||
)
|
||||
|
||||
try await api.realNameSubmit(payload)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
XCTAssertEqual(request.httpMethod, "POST")
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/real-name/submit")
|
||||
XCTAssertEqual(object["real_name"] as? String, "张三")
|
||||
XCTAssertEqual(object["id_card_no"] as? String, "11010519491231002X")
|
||||
XCTAssertEqual(object["sms_verify_code"] as? String, "123456")
|
||||
XCTAssertEqual(object["is_long_valid"] as? Int, 1)
|
||||
XCTAssertEqual(object["front_url"] as? String, "front")
|
||||
XCTAssertEqual(object["back_url"] as? String, "back")
|
||||
}
|
||||
}
|
||||
@ -3,17 +3,157 @@
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 实名认证 ViewModel 测试。
|
||||
@MainActor
|
||||
final class RealNameAuthViewModelTests: XCTestCase {
|
||||
func testValidationRequiresSmsCodeWhenNotVerified() {
|
||||
func testNewApplicationStartsAtIdentityStepAndRequiresName() {
|
||||
let viewModel = RealNameAuthViewModel()
|
||||
|
||||
XCTAssertEqual(viewModel.mode, .newApplication)
|
||||
XCTAssertEqual(viewModel.currentStep, .identity)
|
||||
XCTAssertFalse(viewModel.isAgreed)
|
||||
XCTAssertFalse(viewModel.canGoNextStep)
|
||||
XCTAssertEqual(viewModel.validationMessage, "请输入真实姓名")
|
||||
}
|
||||
|
||||
func testInputFilteringMatchesFormRules() {
|
||||
let viewModel = RealNameAuthViewModel()
|
||||
|
||||
viewModel.updateIdCardNo("11010519491231002xABC")
|
||||
viewModel.updateSmsCode("1a2b345678901234")
|
||||
|
||||
XCTAssertEqual(viewModel.idCardNo, "11010519491231002X")
|
||||
XCTAssertEqual(viewModel.smsCode, "12345678901")
|
||||
}
|
||||
|
||||
func testModesFollowAuditStatus() throws {
|
||||
XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 1)).mode, .pending)
|
||||
XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 2)).mode, .approved)
|
||||
XCTAssertEqual(RealNameAuthViewModel(info: try makeInfo(status: 3)).mode, .rejected)
|
||||
}
|
||||
|
||||
func testRejectedInfoIsEditableAndCanAdvance() throws {
|
||||
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
|
||||
|
||||
XCTAssertTrue(viewModel.isAgreed)
|
||||
XCTAssertTrue(viewModel.canGoNextStep)
|
||||
XCTAssertNil(viewModel.goToVerificationStep())
|
||||
XCTAssertEqual(viewModel.currentStep, .verification)
|
||||
XCTAssertFalse(viewModel.canSubmit)
|
||||
}
|
||||
|
||||
func testReadOnlyInfoCanBrowseSecondStepButCannotSubmit() throws {
|
||||
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 2))
|
||||
|
||||
XCTAssertNil(viewModel.goToVerificationStep())
|
||||
XCTAssertEqual(viewModel.currentStep, .verification)
|
||||
XCTAssertFalse(viewModel.canSubmit)
|
||||
viewModel.updateRealName("不能修改")
|
||||
XCTAssertEqual(viewModel.realName, "张三")
|
||||
}
|
||||
|
||||
func testLongValidityClearsEndDate() throws {
|
||||
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
|
||||
XCTAssertNotNil(viewModel.endDate)
|
||||
|
||||
viewModel.updateLongValid(true)
|
||||
|
||||
XCTAssertTrue(viewModel.isLongValid)
|
||||
XCTAssertNil(viewModel.endDate)
|
||||
XCTAssertTrue(viewModel.canGoNextStep)
|
||||
}
|
||||
|
||||
func testEndDateCannotBeEarlierThanStartDate() throws {
|
||||
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
|
||||
|
||||
viewModel.updateEndDate(Self.date("2026-07-12"))
|
||||
|
||||
XCTAssertFalse(viewModel.canGoNextStep)
|
||||
XCTAssertEqual(viewModel.validationMessage, "证件结束日期不能早于起始日期")
|
||||
}
|
||||
|
||||
func testAgreementControlsStepTransition() throws {
|
||||
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
|
||||
XCTAssertTrue(viewModel.canGoNextStep)
|
||||
|
||||
viewModel.toggleAgreement()
|
||||
|
||||
XCTAssertFalse(viewModel.canGoNextStep)
|
||||
XCTAssertEqual(viewModel.goToVerificationStep(), "请阅读并同意用户协议与隐私政策")
|
||||
XCTAssertEqual(viewModel.currentStep, .identity)
|
||||
}
|
||||
|
||||
func testImmediateUploadsProduceURLsAndEnableNextStep() async throws {
|
||||
let viewModel = RealNameAuthViewModel()
|
||||
viewModel.updateRealName("张三")
|
||||
viewModel.updateIdCardNo("11010519491231002X")
|
||||
viewModel.updateStartDate(Self.date("2026-07-13"))
|
||||
viewModel.updateEndDate(Self.date("2036-07-13"))
|
||||
viewModel.toggleAgreement()
|
||||
let imageData = try XCTUnwrap(Self.imageData())
|
||||
let uploader = MockRealNameOSSUploadService()
|
||||
|
||||
XCTAssertEqual(viewModel.validationMessage, "请输入短信验证码")
|
||||
try await viewModel.uploadIdentityImage(data: imageData, side: .back, uploader: uploader, scenicId: 9, timestamp: 1)
|
||||
try await viewModel.uploadIdentityImage(data: imageData, side: .front, uploader: uploader, scenicId: 9, timestamp: 2)
|
||||
|
||||
XCTAssertEqual(viewModel.backUrl, "https://cdn.example.com/real_name_back_1.jpg")
|
||||
XCTAssertEqual(viewModel.frontUrl, "https://cdn.example.com/real_name_front_2.jpg")
|
||||
XCTAssertEqual(viewModel.backUploadProgress, 100)
|
||||
XCTAssertTrue(viewModel.canGoNextStep)
|
||||
|
||||
viewModel.deleteIdentityImage(side: .front)
|
||||
XCTAssertTrue(viewModel.frontUrl.isEmpty)
|
||||
XCTAssertFalse(viewModel.canGoNextStep)
|
||||
}
|
||||
|
||||
func testUploadFailureClearsPendingPreviewAndURL() async throws {
|
||||
let viewModel = RealNameAuthViewModel()
|
||||
let imageData = try XCTUnwrap(Self.imageData())
|
||||
|
||||
do {
|
||||
try await viewModel.uploadIdentityImage(
|
||||
data: imageData,
|
||||
side: .front,
|
||||
uploader: MockRealNameOSSUploadService(shouldFail: true),
|
||||
scenicId: 9
|
||||
)
|
||||
XCTFail("Expected upload failure")
|
||||
} catch {
|
||||
XCTAssertTrue(viewModel.frontUrl.isEmpty)
|
||||
XCTAssertNil(viewModel.frontPreviewImageData)
|
||||
XCTAssertFalse(viewModel.isFrontUploading)
|
||||
}
|
||||
}
|
||||
|
||||
func testRequestUsesNormalizedFieldsAndLongTermDate() throws {
|
||||
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
|
||||
viewModel.updateLongValid(true)
|
||||
viewModel.updateSmsCode("12a3456")
|
||||
_ = viewModel.goToVerificationStep()
|
||||
|
||||
let request = try viewModel.makeRequest()
|
||||
|
||||
XCTAssertEqual(request.idCardNo, "11010519491231002X")
|
||||
XCTAssertEqual(request.smsVerifyCode, "123456")
|
||||
XCTAssertEqual(request.isLongValid, 1)
|
||||
XCTAssertEqual(request.endDate, "")
|
||||
}
|
||||
|
||||
func testSendCodeStartsSixtySecondCountdown() async throws {
|
||||
let viewModel = RealNameAuthViewModel(info: try makeInfo(status: 3))
|
||||
_ = viewModel.goToVerificationStep()
|
||||
let response = Data(#"{"code":100000,"msg":"success","data":{}}"#.utf8)
|
||||
let api = ProfileAPI(client: APIClient(environment: .testing, session: MockURLSession(responses: [response])))
|
||||
|
||||
try await viewModel.sendCode(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.otpCountdown, 60)
|
||||
XCTAssertFalse(viewModel.canRequestCode)
|
||||
XCTAssertEqual(viewModel.statusMessage, "验证码发送成功")
|
||||
}
|
||||
|
||||
func testIsValidMainlandIDCardNumberChecksChecksum() {
|
||||
@ -27,4 +167,67 @@ final class RealNameAuthViewModelTests: XCTestCase {
|
||||
"11010519491231002X"
|
||||
)
|
||||
}
|
||||
|
||||
private func makeInfo(status: Int) throws -> RealNameInfo {
|
||||
let json = Data(
|
||||
"""
|
||||
{
|
||||
"real_name":"张三",
|
||||
"id_card_no":"11010519491231002X",
|
||||
"audit_status":\(status),
|
||||
"audit_status_text":"审核状态",
|
||||
"start_date":"2026-07-13",
|
||||
"end_date":"2036-07-13",
|
||||
"is_long_valid":0,
|
||||
"front_url":"https://cdn.example.com/front.jpg",
|
||||
"back_url":"https://cdn.example.com/back.jpg",
|
||||
"auditor":{"id":1,"name":"审核员"},
|
||||
"audit_at":"2026-07-13 12:00:00",
|
||||
"reject_reason":"照片模糊"
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
return try JSONDecoder().decode(RealNameInfo.self, from: json)
|
||||
}
|
||||
|
||||
private static func date(_ value: String) -> Date {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.date(from: value)!
|
||||
}
|
||||
|
||||
private static func imageData() -> Data? {
|
||||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 24))
|
||||
let image = renderer.image { context in
|
||||
UIColor.white.setFill()
|
||||
context.fill(CGRect(x: 0, y: 0, width: 40, height: 24))
|
||||
}
|
||||
return image.jpegData(compressionQuality: 0.9)
|
||||
}
|
||||
}
|
||||
|
||||
/// 实名认证上传测试替身。
|
||||
@MainActor
|
||||
private struct MockRealNameOSSUploadService: OSSUploadServing {
|
||||
let shouldFail: Bool
|
||||
|
||||
init(shouldFail: Bool = false) {
|
||||
self.shouldFail = shouldFail
|
||||
}
|
||||
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
"https://cdn.example.com/avatar.jpg"
|
||||
}
|
||||
|
||||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if shouldFail { throw URLError(.cannotWriteToFile) }
|
||||
onProgress(100)
|
||||
return "https://cdn.example.com/\(fileName)"
|
||||
}
|
||||
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
"https://cdn.example.com/bank.jpg"
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,4 +106,52 @@ final class TravelAlbumOTGCoreTests: XCTestCase {
|
||||
|
||||
XCTAssertEqual(key, "punch_point/20260709/u900/aaaaaaaabbbbccccddddeeeeeeeeeeee_guide_photo.jpg")
|
||||
}
|
||||
|
||||
func testOSSRealNameObjectKeyUsesAndroidUserPath() {
|
||||
let previousUserId = AppStore.shared.session.userId
|
||||
AppStore.shared.session.userId = "u901"
|
||||
defer { AppStore.shared.session.userId = previousUserId }
|
||||
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
let date = calendar.date(from: DateComponents(year: 2026, month: 7, day: 13))!
|
||||
let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
|
||||
|
||||
let key = OSSUploadPolicy.objectKey(
|
||||
fileName: "id/front.jpg",
|
||||
scenicId: 9,
|
||||
moduleType: "real_name_auth",
|
||||
date: date,
|
||||
uuid: uuid,
|
||||
timeZone: TimeZone(secondsFromGMT: 0)!
|
||||
)
|
||||
|
||||
XCTAssertEqual(key, "real_name_auth/20260713/u901/11111111222233334444555555555555_id_front.jpg")
|
||||
}
|
||||
|
||||
func testOSSRealNamePathDoesNotChangeAvatarOrBankCardModules() {
|
||||
let date = Date(timeIntervalSince1970: 0)
|
||||
let uuid = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!
|
||||
let timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
|
||||
let avatar = OSSUploadPolicy.objectKey(
|
||||
fileName: "avatar.jpg",
|
||||
scenicId: 9,
|
||||
moduleType: "user_avatar",
|
||||
date: date,
|
||||
uuid: uuid,
|
||||
timeZone: timeZone
|
||||
)
|
||||
let bankCard = OSSUploadPolicy.objectKey(
|
||||
fileName: "bank.jpg",
|
||||
scenicId: 9,
|
||||
moduleType: "bank_card",
|
||||
date: date,
|
||||
uuid: uuid,
|
||||
timeZone: timeZone
|
||||
)
|
||||
|
||||
XCTAssertTrue(avatar.hasPrefix("avatar/19700101/9/"))
|
||||
XCTAssertTrue(bankCard.hasPrefix("bank_card/19700101/9/"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import UIKit
|
||||
@testable import suixinkan
|
||||
|
||||
/// 钱包功能模型、API 和 ViewModel 测试。
|
||||
@ -71,6 +72,8 @@ final class WalletFeatureTests: XCTestCase {
|
||||
XCTAssertEqual(Self.queryItems(from: session.requests[1])["page"], "1")
|
||||
XCTAssertEqual(Self.queryItems(from: session.requests[2])["start_date"], "2026-07-01")
|
||||
XCTAssertEqual(Self.queryItems(from: session.requests[6])["staff_id"], "9")
|
||||
XCTAssertNil(session.requests[4].httpBody)
|
||||
XCTAssertNil(session.requests[6].httpBody)
|
||||
XCTAssertEqual(Self.bodyString(from: session.requests[5]).contains(#""sms_code":"123456""#), true)
|
||||
XCTAssertEqual(Self.bodyString(from: session.requests[7]).contains(#""points":24"#), true)
|
||||
XCTAssertEqual(Self.bodyString(from: session.requests[8]).contains(#""page_size":10"#), true)
|
||||
@ -96,6 +99,46 @@ final class WalletFeatureTests: XCTestCase {
|
||||
XCTAssertGreaterThanOrEqual(api.earningQueries.count, 2)
|
||||
}
|
||||
|
||||
func testWalletFiltersUseAndroidDateOffsets() async {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
let now = calendar.date(from: DateComponents(year: 2026, month: 7, day: 13, hour: 12))!
|
||||
let api = FakeWalletPageService()
|
||||
let viewModel = WalletViewModel(
|
||||
staffIdProvider: { 9 },
|
||||
calendar: calendar,
|
||||
nowProvider: { now }
|
||||
)
|
||||
|
||||
await viewModel.refreshTransaction(api: api)
|
||||
await viewModel.selectFilter(.last30Days, api: api)
|
||||
await viewModel.selectFilter(.last180Days, api: api)
|
||||
|
||||
XCTAssertEqual(api.earningQueries.map(\.start), ["2026-07-06", "2026-06-13", "2026-01-14"])
|
||||
XCTAssertEqual(api.earningQueries.map(\.end), ["2026-07-13", "2026-07-13", "2026-07-13"])
|
||||
}
|
||||
|
||||
func testRefreshFailureKeepsWalletAndPointRecords() async {
|
||||
let api = FakeWalletPageService()
|
||||
let walletViewModel = WalletViewModel(staffIdProvider: { 9 })
|
||||
let pointsViewModel = PointsRedemptionViewModel(staffIdProvider: { 9 })
|
||||
|
||||
await walletViewModel.refreshWithdraw(api: api)
|
||||
await walletViewModel.refreshTransaction(api: api)
|
||||
await pointsViewModel.refreshWithdrawList(api: api)
|
||||
api.shouldFailWithdrawList = true
|
||||
api.shouldFailEarningDetail = true
|
||||
api.shouldFailPointWithdrawList = true
|
||||
|
||||
await walletViewModel.refreshWithdraw(api: api)
|
||||
await walletViewModel.refreshTransaction(api: api)
|
||||
await pointsViewModel.refreshWithdrawList(api: api)
|
||||
|
||||
XCTAssertEqual(walletViewModel.withdrawRecords.count, 1)
|
||||
XCTAssertEqual(walletViewModel.transactionGroups.count, 1)
|
||||
XCTAssertEqual(pointsViewModel.withdrawRecords.count, 1)
|
||||
}
|
||||
|
||||
func testWalletWithdrawDestinationFlow() async throws {
|
||||
let viewModel = WalletViewModel()
|
||||
|
||||
@ -107,6 +150,12 @@ final class WalletFeatureTests: XCTestCase {
|
||||
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
|
||||
XCTAssertEqual(destination, .realNamePending)
|
||||
|
||||
profile = FakeWalletProfileService(realNameStatus: 3, bankCardStatus: nil)
|
||||
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
|
||||
guard case .realNameAudit = destination else {
|
||||
return XCTFail("实名认证驳回应进入审核页")
|
||||
}
|
||||
|
||||
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: nil)
|
||||
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
|
||||
XCTAssertEqual(destination, .withdrawalSettings)
|
||||
@ -115,6 +164,12 @@ final class WalletFeatureTests: XCTestCase {
|
||||
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
|
||||
XCTAssertEqual(destination, .bankCardPending)
|
||||
|
||||
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 3)
|
||||
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
|
||||
guard case .withdrawalAudit = destination else {
|
||||
return XCTFail("银行卡驳回应进入审核页")
|
||||
}
|
||||
|
||||
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 2)
|
||||
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
|
||||
XCTAssertEqual(destination, .withdraw)
|
||||
@ -129,11 +184,21 @@ final class WalletFeatureTests: XCTestCase {
|
||||
viewModel.updateVerificationCode("12a456")
|
||||
|
||||
XCTAssertEqual(viewModel.amount, "12.34")
|
||||
XCTAssertEqual(viewModel.verificationCode, "12456")
|
||||
XCTAssertEqual(viewModel.verificationCode, "12a456")
|
||||
|
||||
let success = await viewModel.submit(api: api)
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(api.withdrawApplyRequests.first?.amount, "12.34")
|
||||
|
||||
viewModel.updateAmount("0")
|
||||
XCTAssertNil(viewModel.validationMessage)
|
||||
viewModel.updateAmount("99")
|
||||
XCTAssertEqual(viewModel.validationMessage, "金额不能大于可提现金额")
|
||||
|
||||
await viewModel.requestVerificationCode(api: api)
|
||||
XCTAssertEqual(viewModel.countdown, 60)
|
||||
viewModel.decrementCountdown()
|
||||
XCTAssertEqual(viewModel.countdown, 59)
|
||||
}
|
||||
|
||||
func testPointsRedemptionViewModelClampsAndRefreshes() async {
|
||||
@ -141,6 +206,8 @@ final class WalletFeatureTests: XCTestCase {
|
||||
let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 })
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
viewModel.updatePointsInput("0")
|
||||
XCTAssertEqual(viewModel.pointsInput, "0")
|
||||
viewModel.updatePointsInput("999")
|
||||
XCTAssertEqual(viewModel.pointsInput, "60")
|
||||
|
||||
@ -151,6 +218,18 @@ final class WalletFeatureTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.withdrawRecords.count, 1)
|
||||
}
|
||||
|
||||
func testWalletTokensAndHomeIconMatchAndroid() {
|
||||
XCTAssertEqual(WalletTokens.pageBackground.walletHexRGB, 0xF5F6F8)
|
||||
XCTAssertEqual(WalletTokens.withdrawPageBackground.walletHexRGB, 0xF9FAFB)
|
||||
XCTAssertEqual(WalletTokens.summaryBackground.walletHexRGB, 0x1677FF)
|
||||
XCTAssertEqual(WalletTokens.primary.walletHexRGB, 0x0073FF)
|
||||
XCTAssertEqual(WalletTokens.summaryRadius, 24)
|
||||
XCTAssertEqual(WalletTokens.contentRadius, 16)
|
||||
XCTAssertEqual(WalletTokens.cardRadius, 12)
|
||||
XCTAssertEqual(HomeMenuCatalog.item(for: "wallet")?.iconName, "home_menu_wallet")
|
||||
XCTAssertNotNil(UIImage(named: "home_menu_wallet"))
|
||||
}
|
||||
|
||||
private static func envelope(_ dataJSON: String) -> Data {
|
||||
Data(#"{"code":100000,"msg":"success","data":\#(dataJSON)}"#.utf8)
|
||||
}
|
||||
@ -175,6 +254,9 @@ private final class FakeWalletPageService: WalletPageServing {
|
||||
private(set) var earningQueries: [(start: String, end: String, page: Int)] = []
|
||||
private(set) var withdrawApplyRequests: [(amount: String, smsCode: String)] = []
|
||||
private(set) var pointApplyRequests: [Int] = []
|
||||
var shouldFailWithdrawList = false
|
||||
var shouldFailEarningDetail = false
|
||||
var shouldFailPointWithdrawList = false
|
||||
|
||||
func walletSummary(type: Int) async throws -> WalletSummaryResponse {
|
||||
WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "80.00", amountWithdrawable: "50.00")
|
||||
@ -191,6 +273,7 @@ private final class FakeWalletPageService: WalletPageServing {
|
||||
}
|
||||
|
||||
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
|
||||
if shouldFailWithdrawList { throw FakeWalletError.requestFailed }
|
||||
WalletWithdrawListResponse(
|
||||
total: 1,
|
||||
item: [WalletWithdrawItem(id: page, amount: "10.00", createdAt: "2026-07-01", settlementStatus: 40, statusLabel: "打款中")]
|
||||
@ -198,6 +281,7 @@ private final class FakeWalletPageService: WalletPageServing {
|
||||
}
|
||||
|
||||
func earningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> EarningDetailResponse {
|
||||
if shouldFailEarningDetail { throw FakeWalletError.requestFailed }
|
||||
earningQueries.append((startDate, endDate, page))
|
||||
return EarningDetailResponse(
|
||||
totalAmount: "12.30",
|
||||
@ -242,10 +326,18 @@ private final class FakeWalletPageService: WalletPageServing {
|
||||
}
|
||||
|
||||
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
|
||||
if shouldFailPointWithdrawList { throw FakeWalletError.requestFailed }
|
||||
PointWithdrawListResponse(total: 1, list: [PointWithdrawItem(id: page, points: 24, amount: 2, status: 1, createdAt: "2026-07-01")])
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包测试替身错误。
|
||||
private enum FakeWalletError: LocalizedError {
|
||||
case requestFailed
|
||||
|
||||
var errorDescription: String? { "请求失败" }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 钱包前置资料服务测试替身。
|
||||
private struct FakeWalletProfileService: WalletProfileServing {
|
||||
@ -270,3 +362,14 @@ private struct FakeWalletProfileService: WalletProfileServing {
|
||||
return try JSONDecoder().decode(BankCardInfoResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
var walletHexRGB: UInt {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
var alpha: CGFloat = 0
|
||||
getRed(&red, green: &green, blue: &blue, alpha: &alpha)
|
||||
return UInt(Int(round(red * 255)) << 16 | Int(round(green * 255)) << 8 | Int(round(blue * 255)))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user