新增运营区域与飞手认证模块,并完善直播推流就绪流程
将运营区域与飞手认证从首页占位页迁移为完整模块,扩展 Live 播放与推流就绪流程,并新增飞手证书 OSS 上传。 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