Add OperatingArea and PilotCertification modules with live push readiness.
Migrate operating area and pilot certification from home placeholders, extend Live with playback and push readiness flows, and add pilot certificate OSS upload. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
277
suixinkanTests/PilotCertification/PilotCertificationTests.swift
Normal file
277
suixinkanTests/PilotCertification/PilotCertificationTests.swift
Normal file
@ -0,0 +1,277 @@
|
||||
//
|
||||
// PilotCertificationTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user