Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,277 @@
|
||||
//
|
||||
// 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.
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user