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