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:
38
suixinkanTests/Fixtures/flyer_detail_success.json
Normal file
38
suixinkanTests/Fixtures/flyer_detail_success.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "801",
|
||||
"flyer_nickname": "航拍摄影师",
|
||||
"account_id": "42",
|
||||
"realname_status": "2",
|
||||
"status": "1",
|
||||
"status_text": "审核中",
|
||||
"created_at": "2026-05-23 04:11:00",
|
||||
"updated_at": "2026-05-23 04:12:00",
|
||||
"reviewer": "审核员",
|
||||
"review_time": "2026-05-23 04:15:00",
|
||||
"reject_reason": "",
|
||||
"certificate_type": "2",
|
||||
"certificate_no": "UAS-2026-001",
|
||||
"certificate_start_date": "2026-01-01",
|
||||
"certificate_end_date": "2028-01-01",
|
||||
"certificate_image": "https://cdn.example.com/flyer/cert.jpg",
|
||||
"drone_model": "DJI Mini",
|
||||
"drone_sn": "SN20260523001",
|
||||
"contact_phone": "13800000000",
|
||||
"realname_status_text": "已实名",
|
||||
"flyers_certification_logs": [
|
||||
{
|
||||
"id": "9001",
|
||||
"flyer_id": "801",
|
||||
"operator": "审核员",
|
||||
"action": "2",
|
||||
"action_text": "通过",
|
||||
"reject_reason": "",
|
||||
"remark": "资料齐全",
|
||||
"created_at": "2026-05-23 04:15:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
8
suixinkanTests/Fixtures/operating_area_empty.json
Normal file
8
suixinkanTests/Fixtures/operating_area_empty.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 0,
|
||||
"list": []
|
||||
}
|
||||
}
|
||||
46
suixinkanTests/Fixtures/operating_area_success.json
Normal file
46
suixinkanTests/Fixtures/operating_area_success.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 4,
|
||||
"list": [
|
||||
{
|
||||
"id": "101",
|
||||
"name": "东门门店",
|
||||
"business_map_area": [[[118.781, 32.041], [118.782, 32.041], [118.782, 32.042], [118.781, 32.041]]],
|
||||
"status_text": "启用",
|
||||
"type_text": "门店",
|
||||
"audit_status_text": "通过"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"name": "西门门店",
|
||||
"business_map_area": "{\"type\":\"Polygon\",\"coordinates\":[[[118.791,32.051],[118.792,32.051],[118.792,32.052],[118.791,32.051]]]}",
|
||||
"status_text": "启用",
|
||||
"type_text": "门店",
|
||||
"audit_status_text": "通过"
|
||||
},
|
||||
{
|
||||
"id": 103,
|
||||
"name": "北区",
|
||||
"business_map_area": {
|
||||
"type": "MultiPolygon",
|
||||
"coordinates": [
|
||||
[[[118.801, 32.061], [118.802, 32.061], [118.802, 32.062], [118.801, 32.061]]]
|
||||
]
|
||||
},
|
||||
"status_text": "启用",
|
||||
"type_text": "区域",
|
||||
"audit_status_text": "通过"
|
||||
},
|
||||
{
|
||||
"id": 104,
|
||||
"name": "无效区域",
|
||||
"business_map_area": [[118.811, 32.071], [118.812, 32.071]],
|
||||
"status_text": "停用",
|
||||
"type_text": "区域",
|
||||
"audit_status_text": "待审核"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -64,14 +64,8 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_stream_management", title: ""), .destination(.liveManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "live_album", title: ""), .destination(.liveAlbum))
|
||||
}
|
||||
|
||||
/// 测试已知但未迁移的首页路由会进入安全占位。
|
||||
func testKnownUnmigratedRoutesResolveToPlaceholders() {
|
||||
XCTAssertEqual(
|
||||
HomeMenuRouter.resolve(uri: "operating-area", title: ""),
|
||||
.destination(.modulePlaceholder(uri: "operating-area", title: "运营区域"))
|
||||
)
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "operating-area", title: ""), .destination(.operatingArea))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pilot_cert", title: ""), .destination(.pilotCertification))
|
||||
}
|
||||
|
||||
/// 测试 Tab 路由仍集中在 HomeMenuRouter。
|
||||
|
||||
@ -192,6 +192,107 @@ final class LiveViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(api.albumDeleteFileRequests.first?.fileIds, [11])
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [12])
|
||||
}
|
||||
|
||||
func testLiveEntityDecodesPlaybackURLsAndResolverExcludesRTMP() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": "1",
|
||||
"title": "播放测试",
|
||||
"cover_img": "https://cdn.example.com/c.jpg",
|
||||
"push_url": "rtmp://push.example.com/live/1",
|
||||
"play_url": "rtmp://pull.example.com/live/1",
|
||||
"pull_url": "https://cdn.example.com/live/1.flv",
|
||||
"hls_url": "https://cdn.example.com/live/1.m3u8",
|
||||
"live_url": "https://cdn.example.com/live/1.mp4"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let live = try JSONDecoder().decode(LiveEntity.self, from: json)
|
||||
|
||||
XCTAssertEqual(live.playUrl, "rtmp://pull.example.com/live/1")
|
||||
XCTAssertEqual(live.pullUrl, "https://cdn.example.com/live/1.flv")
|
||||
XCTAssertEqual(live.hlsUrl, "https://cdn.example.com/live/1.m3u8")
|
||||
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pushUrl))
|
||||
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.playUrl))
|
||||
XCTAssertNil(LivePlaybackURLResolver.playableURL(from: live.pullUrl))
|
||||
XCTAssertEqual(LivePlaybackURLResolver.playableURL(from: live)?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
}
|
||||
|
||||
func testLivePlaybackViewModelStateTransitions() {
|
||||
let viewModel = LivePlaybackViewModel(urlString: "https://cdn.example.com/live/1.m3u8")
|
||||
|
||||
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
XCTAssertFalse(viewModel.isPlaying)
|
||||
|
||||
viewModel.play()
|
||||
XCTAssertTrue(viewModel.isPlaying)
|
||||
XCTAssertNotNil(viewModel.player)
|
||||
|
||||
viewModel.pause()
|
||||
XCTAssertFalse(viewModel.isPlaying)
|
||||
|
||||
viewModel.release()
|
||||
XCTAssertNil(viewModel.player)
|
||||
XCTAssertEqual(viewModel.playableURL?.absoluteString, "https://cdn.example.com/live/1.m3u8")
|
||||
|
||||
viewModel.load(urlString: "rtmp://push.example.com/live/1")
|
||||
XCTAssertNil(viewModel.playableURL)
|
||||
XCTAssertEqual(viewModel.errorMessage, "暂无可播放地址")
|
||||
}
|
||||
|
||||
func testLivePushReadinessPermissionsNetworkAndUnsupportedAdapter() async {
|
||||
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .wifi)
|
||||
let adapter = LivePushAdapterMock(available: false)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
XCTAssertEqual(viewModel.cameraPermission, .granted)
|
||||
XCTAssertEqual(viewModel.microphonePermission, .granted)
|
||||
XCTAssertEqual(viewModel.networkState, .wifi)
|
||||
XCTAssertEqual(viewModel.sdkStatusText, "未接入真推流 SDK")
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.prepare())
|
||||
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription)
|
||||
}
|
||||
|
||||
func testLivePushReadinessDiagnosticsDoNotPrepareUnsupportedAdapter() async {
|
||||
let permissions = LivePermissionMock(camera: .granted, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .wifi)
|
||||
let adapter = LivePushAdapterMock(available: false)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
XCTAssertThrowsError(try viewModel.runDiagnostics())
|
||||
XCTAssertFalse(adapter.prepareCalled)
|
||||
XCTAssertFalse(adapter.startCalled)
|
||||
XCTAssertEqual(viewModel.errorMessage, LivePushReadinessError.sdkUnavailable.localizedDescription)
|
||||
}
|
||||
|
||||
func testLivePushReadinessBlocksDeniedPermissionAndUnavailableNetwork() async {
|
||||
let permissions = LivePermissionMock(camera: .denied, microphone: .granted)
|
||||
let network = LiveNetworkMock(state: .unavailable)
|
||||
let adapter = LivePushAdapterMock(available: true)
|
||||
let viewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
|
||||
viewModel.configure(pushURL: "rtmp://push.example.com/live/1")
|
||||
viewModel.startMonitoring()
|
||||
await viewModel.refreshPermissions()
|
||||
|
||||
await XCTAssertThrowsErrorAsync(try await viewModel.prepare())
|
||||
XCTAssertFalse(adapter.prepareCalled)
|
||||
|
||||
let missingURLViewModel = LivePushReadinessViewModel(permissionProvider: permissions, networkMonitor: network, adapter: adapter)
|
||||
missingURLViewModel.configure(pushURL: "")
|
||||
await XCTAssertThrowsErrorAsync(try await missingURLViewModel.prepare())
|
||||
XCTAssertEqual(missingURLViewModel.errorMessage, LivePushReadinessError.missingPushURL.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -294,6 +395,69 @@ private final class LiveUploadMock: OSSUploadServing {
|
||||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
}
|
||||
|
||||
private struct LivePermissionMock: LivePermissionProviding {
|
||||
let camera: LivePushPermissionState
|
||||
let microphone: LivePushPermissionState
|
||||
|
||||
func cameraPermission() async -> LivePushPermissionState {
|
||||
camera
|
||||
}
|
||||
|
||||
func microphonePermission() async -> LivePushPermissionState {
|
||||
microphone
|
||||
}
|
||||
}
|
||||
|
||||
private final class LiveNetworkMock: LiveNetworkMonitoring {
|
||||
private(set) var currentState: LivePushNetworkState
|
||||
private var onChange: (@Sendable (LivePushNetworkState) -> Void)?
|
||||
|
||||
init(state: LivePushNetworkState) {
|
||||
currentState = state
|
||||
}
|
||||
|
||||
func start(_ onChange: @escaping @Sendable (LivePushNetworkState) -> Void) {
|
||||
self.onChange = onChange
|
||||
onChange(currentState)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
onChange = nil
|
||||
}
|
||||
}
|
||||
|
||||
private final class LivePushAdapterMock: LivePushAdapter {
|
||||
let name = "Mock SDK"
|
||||
let isAvailable: Bool
|
||||
var prepareCalled = false
|
||||
var startCalled = false
|
||||
var stopCalled = false
|
||||
|
||||
init(available: Bool) {
|
||||
isAvailable = available
|
||||
}
|
||||
|
||||
func prepare(pushURL: URL) async throws {
|
||||
prepareCalled = true
|
||||
if !isAvailable {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
startCalled = true
|
||||
if !isAvailable {
|
||||
throw LivePushReadinessError.sdkUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async throws {
|
||||
stopCalled = true
|
||||
}
|
||||
|
||||
func dispose() async {}
|
||||
}
|
||||
|
||||
private func live(id: Int, title: String = "直播", status: Int = 1, manualPushMode: Int = 1) -> LiveEntity {
|
||||
LiveEntity(
|
||||
id: id,
|
||||
|
||||
171
suixinkanTests/OperatingArea/OperatingAreaTests.swift
Normal file
171
suixinkanTests/OperatingArea/OperatingAreaTests.swift
Normal file
@ -0,0 +1,171 @@
|
||||
//
|
||||
// OperatingAreaTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 运营区域测试,覆盖接口编码、角色分流和围栏解析。
|
||||
final class OperatingAreaTests: XCTestCase {
|
||||
func testAPIsUseExpectedQueriesAndDecodeFixture() async throws {
|
||||
let session = OperatingRecordingURLSession(data: try TestFixture.data(named: "operating_area_success"))
|
||||
let api = OperatingAreaAPI(client: APIClient(session: session))
|
||||
|
||||
let storeResponse = try await api.storeBusinessArea(storeId: 101)
|
||||
XCTAssertEqual(storeResponse.total, 4)
|
||||
XCTAssertEqual(storeResponse.list.first?.id, 101)
|
||||
var request = try XCTUnwrap(session.requests.last)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/store/business-area")
|
||||
XCTAssertEqual(queryItems(from: request)["store_id"], "101")
|
||||
|
||||
_ = try await api.scenicAdminBusinessArea(scenicId: 88)
|
||||
request = try XCTUnwrap(session.requests.last)
|
||||
XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/business-area")
|
||||
XCTAssertEqual(queryItems(from: request)["scenic_id"], "88")
|
||||
}
|
||||
|
||||
func testViewModelUsesStoreAndScenicModes() async {
|
||||
let api = OperatingMock()
|
||||
api.response = ListPayload(total: 1, list: [area(id: 101)])
|
||||
|
||||
let storeViewModel = OperatingAreaViewModel()
|
||||
await storeViewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertEqual(api.storeIds, [101])
|
||||
XCTAssertEqual(storeViewModel.fenceRings.first?.isCurrentStore, true)
|
||||
|
||||
let scenicViewModel = OperatingAreaViewModel()
|
||||
await scenicViewModel.reload(api: api, accountContext: accountContext(scenicId: 88, storeId: nil), permissionContext: permissionContext(roleName: "景区管理员"))
|
||||
XCTAssertEqual(api.scenicIds, [88])
|
||||
XCTAssertEqual(scenicViewModel.mode, .scenicAdmin(scenicId: 88))
|
||||
}
|
||||
|
||||
func testViewModelBlocksMissingContextAndFailureClearsData() async {
|
||||
let api = OperatingMock()
|
||||
let viewModel = OperatingAreaViewModel()
|
||||
|
||||
await viewModel.reload(api: api, accountContext: accountContext(scenicId: nil, storeId: nil), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertEqual(viewModel.blockReason, .missingStore)
|
||||
XCTAssertTrue(api.storeIds.isEmpty)
|
||||
|
||||
api.response = ListPayload(total: 1, list: [area(id: 101)])
|
||||
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertFalse(viewModel.fenceRings.isEmpty)
|
||||
|
||||
api.error = OperatingTestError.sample
|
||||
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertTrue(viewModel.fenceRings.isEmpty)
|
||||
XCTAssertEqual(viewModel.blockReason, .backendMessage("测试错误"))
|
||||
}
|
||||
|
||||
func testParserSupportsGeoJSONStringArraysAndFiltersInvalidRings() throws {
|
||||
let response = try TestFixture.payload(ListPayload<OperatingAreaItem>.self, named: "operating_area_success")
|
||||
let rings = response.list.flatMap { OperatingAreaParser.parseToRings($0.businessMapArea) }
|
||||
|
||||
XCTAssertEqual(rings.count, 3)
|
||||
XCTAssertEqual(rings.first?.first, OperatingGeoPoint(latitude: 32.041, longitude: 118.781))
|
||||
XCTAssertEqual(rings[1].first, OperatingGeoPoint(latitude: 32.051, longitude: 118.791))
|
||||
XCTAssertEqual(rings[2].first, OperatingGeoPoint(latitude: 32.061, longitude: 118.801))
|
||||
}
|
||||
|
||||
func testEmptyAndUnparsableAreaStates() async {
|
||||
let api = OperatingMock()
|
||||
let viewModel = OperatingAreaViewModel()
|
||||
|
||||
api.response = ListPayload(total: 0, list: [])
|
||||
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertEqual(viewModel.blockReason, .emptyAreaList)
|
||||
|
||||
api.response = ListPayload(total: 1, list: [OperatingAreaItem(id: 1, name: "bad", businessMapArea: .array([.array([.number(1), .number(2)])]))])
|
||||
await viewModel.reload(api: api, accountContext: accountContext(storeId: 101), permissionContext: permissionContext(roleName: "店铺管理员"))
|
||||
XCTAssertEqual(viewModel.blockReason, .noParsableFenceData)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class OperatingMock: OperatingAreaServing {
|
||||
var response = ListPayload<OperatingAreaItem>(total: 0, list: [])
|
||||
var error: Error?
|
||||
var storeIds: [Int] = []
|
||||
var scenicIds: [Int] = []
|
||||
|
||||
func storeBusinessArea(storeId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||
storeIds.append(storeId)
|
||||
if let error { throw error }
|
||||
return response
|
||||
}
|
||||
|
||||
func scenicAdminBusinessArea(scenicId: Int) async throws -> ListPayload<OperatingAreaItem> {
|
||||
scenicIds.append(scenicId)
|
||||
if let error { throw error }
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
private final class OperatingRecordingURLSession: URLSessionProtocol {
|
||||
private let data: Data
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
init(data: Data) {
|
||||
self.data = data
|
||||
}
|
||||
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
private enum OperatingTestError: LocalizedError {
|
||||
case sample
|
||||
|
||||
var errorDescription: String? { "测试错误" }
|
||||
}
|
||||
|
||||
private func area(id: Int) -> OperatingAreaItem {
|
||||
OperatingAreaItem(
|
||||
id: id,
|
||||
name: "区域\(id)",
|
||||
businessMapArea: .array([
|
||||
.array([
|
||||
.array([.number(118.781), .number(32.041)]),
|
||||
.array([.number(118.782), .number(32.041)]),
|
||||
.array([.number(118.782), .number(32.042)])
|
||||
])
|
||||
]),
|
||||
statusText: "启用",
|
||||
typeText: "门店",
|
||||
auditStatusText: "通过"
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func accountContext(scenicId: Int? = 88, storeId: Int? = 101) -> AccountContext {
|
||||
let context = AccountContext()
|
||||
let scenics = scenicId.map { [BusinessScope(id: $0, name: "测试景区", kind: .scenic)] } ?? []
|
||||
let stores = storeId.map { [BusinessScope(id: $0, name: "测试门店", kind: .store, parentScenicId: scenicId)] } ?? []
|
||||
context.replaceScopes(scenic: scenics, stores: stores, currentScenicId: scenicId, currentStoreId: storeId)
|
||||
return context
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func permissionContext(roleName: String) -> PermissionContext {
|
||||
let context = PermissionContext()
|
||||
let role = RoleInfo(id: 1, name: roleName, permission: [PermissionItem(id: 1, name: "运营区域", uri: "operating-area")])
|
||||
context.replaceRolePermissions([RolePermissionResponse(role: role, scenic: [])], currentRoleId: 1)
|
||||
return context
|
||||
}
|
||||
|
||||
private func queryItems(from request: URLRequest) -> [String: String] {
|
||||
guard let url = request.url,
|
||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||
else { return [:] }
|
||||
return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).compactMap { item in
|
||||
item.value.map { (item.name, $0) }
|
||||
})
|
||||
}
|
||||
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.
|
||||
}
|
||||
}
|
||||
@ -55,4 +55,14 @@ extension OSSUploadServing {
|
||||
) async throws -> String {
|
||||
""
|
||||
}
|
||||
|
||||
/// 旧上传替身默认不关心飞手认证入口;飞手认证专用测试会覆盖此方法记录调用。
|
||||
func uploadPilotCertificateImage(
|
||||
data: Data,
|
||||
fileName: String,
|
||||
scenicId: Int,
|
||||
onProgress: @escaping (Int) -> Void
|
||||
) async throws -> String {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user