278 lines
12 KiB
Swift
278 lines
12 KiB
Swift
//
|
||
// 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<T>(
|
||
_ 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.
|
||
}
|
||
}
|