完善实名认证、钱包与打卡点模块 UI 与业务逻辑。

对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 16:28:10 +08:00
parent 07b2b9d459
commit 5138c1c11a
63 changed files with 3101 additions and 745 deletions

View File

@ -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"
}
}