// // PilotCertificationTests.swift // suixinkanTests // // Created by Codex on 2026/6/25. // import XCTest @testable import suixinkan_ios @MainActor /// 飞手认证测试,覆盖接口编码、加载、验证码、校验和提交流程。 final class PilotCertificationTests: XCTestCase { func testAPIsUseExpectedPathsAndBodies() async throws { let session = PilotRecordingURLSession(responses: [ try TestFixture.data(named: "flyer_detail_success"), try TestFixture.data(named: "empty_success"), try TestFixture.data(named: "empty_success"), try TestFixture.data(named: "empty_success") ]) let api = PilotCertificationAPI(client: APIClient(session: session)) let detail = try await api.flyerDetail() XCTAssertEqual(detail.id, 801) XCTAssertEqual(detail.certificateNo, "UAS-2026-001") var request = try XCTUnwrap(session.requests.last) XCTAssertEqual(request.httpMethod, "GET") XCTAssertEqual(request.url?.path, "/api/app/flyer/detail") try await api.flyerSendCode(phone: "13800000000") request = try XCTUnwrap(session.requests.last) XCTAssertEqual(request.url?.path, "/api/app/flyer/send-code") XCTAssertEqual(try pilotJSONBody(from: request)["phone"] as? String, "13800000000") try await api.flyerApply(applyRequest()) request = try XCTUnwrap(session.requests.last) XCTAssertEqual(request.url?.path, "/api/app/flyer/apply") var json = try pilotJSONBody(from: request) XCTAssertEqual(json["name"] as? String, "航拍摄影师") XCTAssertEqual(json["certificate_no"] as? String, "UAS-2026-001") XCTAssertEqual(json["drone_sn"] as? String, "SN20260523001") try await api.flyerEdit(editRequest()) request = try XCTUnwrap(session.requests.last) XCTAssertEqual(request.url?.path, "/api/app/flyer/edit") json = try pilotJSONBody(from: request) XCTAssertEqual(json["id"] as? Int, 801) XCTAssertEqual(json["contact_phone"] as? String, "13800000001") } func testViewModelLoadSendCodeAndCountdown() async throws { let api = PilotMock() api.detailResponses = [try TestFixture.payload(FlyerDetailResponse.self, named: "flyer_detail_success")] let realName = PilotRealNameMock(response: try TestFixture.payload(RealNameInfoResponse.self, named: "real_name_info_success")) let viewModel = PilotCertificationViewModel() await viewModel.load(api: api, realNameAPI: realName) XCTAssertEqual(viewModel.name, "航拍摄影师") XCTAssertEqual(viewModel.certType, .caac) XCTAssertEqual(viewModel.droneSerialNo, "SN20260523001") XCTAssertTrue(viewModel.isRealNameVerified) XCTAssertEqual(viewModel.latestAuditLog?.operatorName, "审核员") let codeViewModel = PilotCertificationViewModel() fillValidForm(codeViewModel) try await codeViewModel.sendCode(api: api) XCTAssertEqual(api.sendCodePhones, ["13800000000"]) XCTAssertEqual(codeViewModel.countdown, 60) codeViewModel.tickCountdown() XCTAssertEqual(codeViewModel.countdown, 59) api.sendCodeError = PilotTestError.sample codeViewModel.phone = "13800000001" while codeViewModel.countdown > 0 { codeViewModel.tickCountdown() } await XCTAssertThrowsErrorAsync(try await codeViewModel.sendCode(api: api)) XCTAssertEqual(codeViewModel.countdown, 0) } func testValidationCoversRequiredFieldsPhoneImageAndDates() { let viewModel = PilotCertificationViewModel() XCTAssertEqual(viewModel.validationMessage, "请输入飞手昵称") fillValidForm(viewModel) viewModel.phone = "123" XCTAssertEqual(viewModel.validationMessage, "请输入有效的手机号码") fillValidForm(viewModel) viewModel.certImageUrl = "" XCTAssertEqual(viewModel.validationMessage, "请选择证件图片") fillValidForm(viewModel) viewModel.endDate = Date(timeInterval: -86_400, since: viewModel.startDate) XCTAssertEqual(viewModel.validationMessage, "截至日期不能早于起始日期") } func testSubmitUploadsAndAppliesThenClearsPendingImage() async throws { let api = PilotMock() let uploader = PilotUploadMock() let viewModel = PilotCertificationViewModel() fillValidForm(viewModel) viewModel.certImageUrl = "" viewModel.prepareCertificateImage(data: Data([1, 2, 3]), fileName: "cert.jpg") uploader.url = "https://cdn.example.com/pilot/cert.jpg" try await viewModel.submit(api: api, uploader: uploader, scenicId: 88) XCTAssertEqual(uploader.uploads.first?.fileName, "cert.jpg") XCTAssertEqual(uploader.uploads.first?.scenicId, 88) XCTAssertEqual(api.applyRequests.first?.certificateImage, "https://cdn.example.com/pilot/cert.jpg") XCTAssertNil(viewModel.pendingCertificateImageData) } func testRejectedSubmitUsesEditAndFailureKeepsForm() async throws { let api = PilotMock() api.detailResponses = [ FlyerDetailResponse(id: 801, name: "旧资料", status: 3, certificateImage: "https://cdn.example.com/old.jpg") ] let viewModel = PilotCertificationViewModel() await viewModel.load(api: api, realNameAPI: PilotRealNameMock()) fillValidForm(viewModel) try await viewModel.submit(api: api, uploader: PilotUploadMock(), scenicId: 88) XCTAssertEqual(api.editRequests.first?.id, 801) let failingUploader = PilotUploadMock() failingUploader.error = PilotTestError.sample viewModel.certImageUrl = "" viewModel.prepareCertificateImage(data: Data([9]), fileName: "new.jpg") await XCTAssertThrowsErrorAsync(try await viewModel.submit(api: api, uploader: failingUploader, scenicId: 88)) XCTAssertEqual(viewModel.pendingCertificateFileName, "new.jpg") } } @MainActor private final class PilotMock: PilotCertificationServing { var detailResponses: [FlyerDetailResponse] = [] var sendCodePhones: [String] = [] var applyRequests: [FlyerApplyRequest] = [] var editRequests: [FlyerEditRequest] = [] var sendCodeError: Error? func flyerDetail() async throws -> FlyerDetailResponse { detailResponses.isEmpty ? FlyerDetailResponse() : detailResponses.removeFirst() } func flyerSendCode(phone: String) async throws { if let sendCodeError { throw sendCodeError } sendCodePhones.append(phone) } func flyerApply(_ request: FlyerApplyRequest) async throws { applyRequests.append(request) } func flyerEdit(_ request: FlyerEditRequest) async throws { editRequests.append(request) } } private struct PilotRealNameMock: PilotRealNameServing { var response = RealNameInfoResponse(realNameInfo: nil) func realNameInfo() async throws -> RealNameInfoResponse { response } } @MainActor private final class PilotUploadMock: OSSUploadServing { var url = "https://cdn.example.com/pilot/cert.jpg" var error: Error? var uploads: [(fileName: String, scenicId: Int)] = [] func uploadPilotCertificateImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { if let error { throw error } uploads.append((fileName, scenicId)) onProgress(100) return url } 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 uploadAliveAlbumFile(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 uploadProjectImage(data: Data, fileName: String, 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 { "" } func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" } } private final class PilotRecordingURLSession: URLSessionProtocol { private var responses: [Data] private(set) var requests: [URLRequest] = [] init(responses: [Data]) { self.responses = responses } func data(for request: URLRequest) async throws -> (Data, URLResponse) { requests.append(request) let data = responses.isEmpty ? try TestFixture.data(named: "empty_success") : responses.removeFirst() return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) } } private enum PilotTestError: LocalizedError { case sample var errorDescription: String? { "测试错误" } } private func fillValidForm(_ viewModel: PilotCertificationViewModel) { viewModel.name = "航拍摄影师" viewModel.certType = .caac viewModel.certNo = "UAS-2026-001" viewModel.certImageUrl = "https://cdn.example.com/flyer/cert.jpg" viewModel.startDate = Date(timeIntervalSince1970: 1_767_225_600) viewModel.endDate = Date(timeIntervalSince1970: 1_830_384_000) viewModel.droneModel = "DJI Mini" viewModel.droneSerialNo = "SN20260523001" viewModel.phone = "13800000000" viewModel.verifyCode = "123456" } private func applyRequest() -> FlyerApplyRequest { FlyerApplyRequest( name: "航拍摄影师", realnameStatus: 1, certificateType: 2, certificateNo: "UAS-2026-001", certificateStartDate: "2026-01-01", certificateEndDate: "2028-01-01", certificateImage: "https://cdn.example.com/flyer/cert.jpg", droneModel: "DJI Mini", droneSn: "SN20260523001", contactPhone: "13800000000", code: "123456" ) } private func editRequest() -> FlyerEditRequest { FlyerEditRequest( id: 801, name: "航拍摄影师", realnameStatus: 1, certificateType: 2, certificateNo: "UAS-2026-001", certificateStartDate: "2026-01-01", certificateEndDate: "2028-01-01", certificateImage: "https://cdn.example.com/flyer/cert.jpg", droneModel: "DJI Mini", droneSn: "SN20260523002", contactPhone: "13800000001", code: "654321" ) } private func pilotJSONBody(from request: URLRequest) throws -> [String: Any] { let body = try XCTUnwrap(request.httpBody) return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) } private func XCTAssertThrowsErrorAsync( _ expression: @autoclosure () async throws -> T, file: StaticString = #filePath, line: UInt = #line ) async { do { _ = try await expression() XCTFail("Expected async expression to throw", file: file, line: line) } catch { // Expected path. } }