对齐 Android 实名认证流程与审核页、钱包提现/积分兑换界面,优化打卡点列表与详情交互,并补充相关资源、主题 token 与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
234 lines
8.8 KiB
Swift
234 lines
8.8 KiB
Swift
//
|
|
// RealNameAuthViewModelTests.swift
|
|
// suixinkanTests
|
|
//
|
|
|
|
import UIKit
|
|
import XCTest
|
|
@testable import suixinkan
|
|
|
|
/// 实名认证 ViewModel 测试。
|
|
@MainActor
|
|
final class RealNameAuthViewModelTests: XCTestCase {
|
|
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()
|
|
|
|
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() {
|
|
XCTAssertTrue(RealNameAuthViewModel.isValidMainlandIDCardNumber("11010519491231002X"))
|
|
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
|
}
|
|
|
|
func testNormalizedIDCardNumberUppercasesSuffix() {
|
|
XCTAssertEqual(
|
|
RealNameAuthViewModel.normalizedIDCardNumber("11010519491231002x"),
|
|
"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"
|
|
}
|
|
}
|