// // OrderLongTailTests.swift // suixinkanTests // // Created by Codex on 2026/6/25. // import XCTest @testable import suixinkan @MainActor /// 订单长尾测试,覆盖押金订单、退款和历史拍摄的 API 与 ViewModel 规则。 final class OrderLongTailTests: XCTestCase { /// 测试押金订单接口使用旧工程路径、分页兜底和请求体。 func testDepositOrderAPIRequestsAndDecodesLossyFields() async throws { let session = LongTailRecordingURLSession(responses: [ try TestFixture.data(named: "deposit_order_list_success"), try TestFixture.data(named: "empty_success"), try TestFixture.data(named: "empty_success") ]) let api = OrdersAPI(client: APIClient(session: session)) let list = try await api.depositOrderList(scenicId: 88, page: 0, pageSize: 0) try await api.depositOrderWriteOff(orderNumber: "DEP001") try await api.depositOrderRefund(orderNumber: "DEP002", refundReason: "游客取消行程") XCTAssertEqual(session.requests.map { $0.url?.path }, [ "/api/yf-handset-app/photog/order/deposit-list", "/api/yf-handset-app/photog/order/deposit-writeoff", "/api/yf-handset-app/photog/order/deposit-refund" ]) let query = longTailQueryItems(from: try XCTUnwrap(session.requests.first)) XCTAssertEqual(query["scenic_id"], "88") XCTAssertEqual(query["page"], "1") XCTAssertEqual(query["page_size"], "1") XCTAssertEqual(list.total, 2) XCTAssertEqual(list.list[1].id, 4102) XCTAssertEqual(list.list[1].userPhone, "13900000000") let writeOffBody = try longTailBodyObject(from: session.requests[1]) XCTAssertEqual(writeOffBody["order_number"] as? String, "DEP001") let refundBody = try longTailBodyObject(from: session.requests[2]) XCTAssertEqual(refundBody["order_number"] as? String, "DEP002") XCTAssertEqual(refundBody["refund_reason"] as? String, "游客取消行程") } /// 测试拍摄信息、普通退款和历史拍摄接口 path、query、body 与宽松解码。 func testShootingRefundAndHistoryAPIRequestsAndDecodes() async throws { let session = LongTailRecordingURLSession(responses: [ try TestFixture.data(named: "store_order_shooting_detail_success"), try TestFixture.data(named: "multi_travel_shoot_history_success"), try TestFixture.data(named: "empty_success") ]) let api = OrdersAPI(client: APIClient(session: session)) let shooting = try await api.storeOrderShootingDetail( storeId: 66, orderNumber: "DEP001", scenicSpotId: 901, photogUid: 42 ) let history = try await api.multiTravelShootHistory(orderNumber: "MT001") try await api.orderRefund( orderNumber: "STORE001", refundType: .partial, refundAmount: "10.50", refundReason: "游客取消部分项目" ) XCTAssertEqual(session.requests.map { $0.url?.path }, [ "/api/app/store/order/shooting-detail", "/api/yf-handset-app/photog/order/multi-travel/shoot-history", "/api/yf-handset-app/photog/order/refund" ]) let shootingQuery = longTailQueryItems(from: session.requests[0]) XCTAssertEqual(shootingQuery["store_id"], "66") XCTAssertEqual(shootingQuery["order_number"], "DEP001") XCTAssertEqual(shootingQuery["scenic_spot_id"], "901") XCTAssertEqual(shootingQuery["photog_uid"], "42") XCTAssertEqual(shooting.orderComment?.starShooting, 5) XCTAssertEqual(shooting.materialList.first?.fileSize, 10_485_760) XCTAssertTrue(shooting.materialList.first?.isVideo == true) XCTAssertEqual(longTailQueryItems(from: session.requests[1])["order_number"], "MT001") XCTAssertEqual(history.projectType, 19) XCTAssertEqual(history.photogSpotList.first?.photographerDisplayName, "小景") XCTAssertEqual(history.photogSpotList.first?.files.count, 2) let refundBody = try longTailBodyObject(from: session.requests[2]) XCTAssertEqual(refundBody["order_number"] as? String, "STORE001") XCTAssertEqual(refundBody["refund_type"] as? Int, OrderRefundMode.partial.rawValue) XCTAssertEqual(refundBody["refund_amount"] as? String, "10.50") XCTAssertNil(refundBody["store_id"]) } /// 测试已核销打卡点和多点旅拍素材提交接口 path、query、body。 func testMultiTravelTaskUploadAPIRequestsAndDecodes() async throws { let session = LongTailRecordingURLSession(responses: [ try TestFixture.data(named: "multi_travel_verified_spots_success"), try TestFixture.data(named: "empty_success") ]) let api = OrdersAPI(client: APIClient(session: session)) let spots = try await api.multiTravelVerifiedScenicSpotList(orderNumber: "MT001") try await api.multiTravelUploadMaterial( MultiTravelUploadMaterialRequest( orderNumber: "MT001", scenicSpotId: 901, cloudFile: [MultiTravelCloudFileItem(fileId: 7001)], uploadFile: [MultiTravelUploadFileItem(fileName: "spot.jpg", fileUrl: "https://cdn.example.com/spot.jpg")] ) ) XCTAssertEqual(session.requests.map { $0.url?.path }, [ "/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list", "/api/yf-handset-app/photog/order/multi-travel/upload-material" ]) XCTAssertEqual(longTailQueryItems(from: session.requests[0])["order_number"], "MT001") XCTAssertEqual(spots.map(\.id), [901, 902]) XCTAssertEqual(spots.last?.name, "观景台") let body = try longTailBodyObject(from: session.requests[1]) XCTAssertEqual(body["order_number"] as? String, "MT001") XCTAssertEqual(body["scenic_spot_id"] as? Int, 901) XCTAssertEqual((body["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 7001) XCTAssertEqual((body["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/spot.jpg") } /// 测试押金订单无景区时清空旧数据且不请求接口。 func testDepositListClearsWithoutScenicAndStopsAtLastPage() async throws { let service = LongTailMockOrderService() service.depositListResponses = [try Self.depositPayload()] let viewModel = DepositOrderListViewModel() await viewModel.reload(api: service, scenicId: 88, reset: true) await viewModel.reload(api: service, scenicId: 88, reset: false) await viewModel.reload(api: service, scenicId: nil, reset: true) XCTAssertTrue(viewModel.orders.isEmpty) XCTAssertEqual(viewModel.total, 0) XCTAssertEqual(viewModel.page, 1) XCTAssertEqual(service.depositListCalls, [ .init(scenicId: 88, page: 1, pageSize: 10) ]) } /// 测试押金核销和押金退款成功后刷新列表,失败时保留旧列表并清理操作状态。 func testDepositWriteOffAndRefundRefreshOrKeepListOnFailure() async throws { let service = LongTailMockOrderService() service.depositListResponses = [ try Self.depositPayload(), try Self.depositPayload(), try Self.depositPayload() ] let viewModel = DepositOrderListViewModel() await viewModel.reload(api: service, scenicId: 88, reset: true) let writeOffSuccess = await viewModel.writeOff(api: service, scenicId: 88, orderNumber: "DEP001") let emptyReason = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: " ") service.depositRefundError = APIError.serverCode(500, "退款失败") let refundFailure = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: "游客取消") XCTAssertTrue(writeOffSuccess) XCTAssertFalse(emptyReason) XCTAssertFalse(refundFailure) XCTAssertEqual(service.depositWriteOffNumbers, ["DEP001"]) XCTAssertEqual(service.depositRefundCalls, [.init(orderNumber: "DEP001", reason: "游客取消")]) XCTAssertEqual(service.depositListCalls.count, 2) XCTAssertEqual(viewModel.orders.count, 2) XCTAssertNil(viewModel.operatingOrderNumber) XCTAssertEqual(viewModel.errorMessage, "退款失败") } /// 测试押金详情和拍摄信息缺少门店时不请求接口。 func testDepositDetailAndShootingSkipWithoutStore() async { let service = LongTailMockOrderService() let detailViewModel = DepositOrderDetailViewModel() let shootingViewModel = DepositOrderShootingInfoViewModel() await detailViewModel.load(api: service, storeId: nil, orderNumber: "DEP001") await shootingViewModel.load(api: service, storeId: nil, orderNumber: "DEP001", scenicSpotId: 901, photogUid: 42) XCTAssertTrue(service.storeDetailCalls.isEmpty) XCTAssertTrue(service.storeShootingCalls.isEmpty) XCTAssertEqual(detailViewModel.errorMessage, "当前账号缺少门店信息") XCTAssertEqual(shootingViewModel.errorMessage, "当前账号缺少门店信息") } /// 测试普通退款入口规则、金额校验、重复提交保护和成功提交参数。 func testOrderRefundValidationAndSubmit() async throws { let service = LongTailMockOrderService() let viewModel = OrderRefundViewModel() let refundable = try Self.orderEntity(orderStatus: 18, orderType: 4, actualPayAmount: "99.50") let unavailable = try Self.orderEntity(orderStatus: 50, orderType: 4, actualPayAmount: "99.50") XCTAssertTrue(viewModel.canRefund(refundable)) XCTAssertFalse(viewModel.canRefund(unavailable)) viewModel.begin(item: refundable) XCTAssertEqual(viewModel.amount, "99.50") let missingReasonSuccess = await viewModel.submit(api: service, item: refundable) XCTAssertFalse(missingReasonSuccess) XCTAssertEqual(viewModel.errorMessage, "请输入退款原因") viewModel.reason = "部分退款" viewModel.mode = .partial viewModel.amount = "12.345" let invalidAmountSuccess = await viewModel.submit(api: service, item: refundable) XCTAssertFalse(invalidAmountSuccess) XCTAssertEqual(viewModel.errorMessage, "请输入有效的退款金额") viewModel.amount = "120.00" let overLimitSuccess = await viewModel.submit(api: service, item: refundable) XCTAssertFalse(overLimitSuccess) XCTAssertEqual(viewModel.errorMessage, "退款金额不能大于可退金额") viewModel.amount = "12.3" let submitSuccess = await viewModel.submit(api: service, item: refundable) XCTAssertTrue(submitSuccess) XCTAssertEqual(service.orderRefundCalls, [ .init(orderNumber: "STORE001", mode: .partial, amount: "12.30", reason: "部分退款") ]) XCTAssertNil(viewModel.errorMessage) XCTAssertFalse(viewModel.submitting) } /// 测试历史拍摄空订单不请求,成功后展示项目和媒体,失败后清空旧数据。 func testHistoricalShootingLoadSuccessAndFailure() async throws { let service = LongTailMockOrderService() service.historyResponses = [try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")] let viewModel = HistoricalShootingInfoViewModel() await viewModel.load(api: service, orderNumber: " ") XCTAssertTrue(service.historyCalls.isEmpty) XCTAssertEqual(viewModel.errorMessage, "订单号不能为空") await viewModel.load(api: service, orderNumber: "MT001") XCTAssertEqual(viewModel.projectName, "多点旅拍") XCTAssertEqual(viewModel.spots.first?.scenicSpotName, "湖心亭") XCTAssertEqual(viewModel.spots.first?.files.count, 2) service.historyError = APIError.serverCode(500, "历史拍摄失败") await viewModel.load(api: service, orderNumber: "MT001") XCTAssertTrue(viewModel.spots.isEmpty) XCTAssertEqual(viewModel.errorMessage, "历史拍摄失败") } /// 测试任务上传空订单不请求,加载成功默认选中首个打卡点,加载失败清空状态。 func testMultiTravelTaskUploadLoadsAndRepairsSpotSelection() async throws { let service = LongTailMockOrderService() service.verifiedSpotResponses = [ try Self.verifiedSpots(), [try Self.verifiedSpot(id: 902, name: "观景台")] ] let viewModel = MultiTravelTaskUploadViewModel() await viewModel.loadSpots(api: service) XCTAssertTrue(service.verifiedSpotCalls.isEmpty) viewModel.orderNumber = "MT001" await viewModel.loadSpots(api: service) XCTAssertEqual(viewModel.selectedSpotId, 901) viewModel.selectSpot(id: 901) await viewModel.loadSpots(api: service) XCTAssertEqual(viewModel.selectedSpotId, 902) service.verifiedSpotError = APIError.serverCode(500, "打卡点失败") await viewModel.loadSpots(api: service) XCTAssertTrue(viewModel.spots.isEmpty) XCTAssertNil(viewModel.selectedSpotId) XCTAssertEqual(viewModel.errorMessage, "打卡点失败") } /// 测试任务上传提交校验和仅云盘文件直接提交。 func testMultiTravelTaskUploadValidatesAndSubmitsCloudFiles() async throws { let service = LongTailMockOrderService() service.verifiedSpotResponses = [try Self.verifiedSpots()] let uploader = LongTailMockUploader() let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001") let missingScenicSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: nil) XCTAssertFalse(missingScenicSuccess) XCTAssertEqual(viewModel.errorMessage, "请先选择景区") await viewModel.loadSpots(api: service) let missingFileSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88) XCTAssertFalse(missingFileSuccess) XCTAssertEqual(viewModel.errorMessage, "请至少选择一个素材文件") viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 7001, fileName: "cloud.jpg", fileType: 2, remark: "")]) let submitSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88) XCTAssertTrue(submitSuccess) XCTAssertTrue(viewModel.didSubmitSuccessfully) XCTAssertEqual(service.uploadMaterialRequests, [ MultiTravelUploadMaterialRequest( orderNumber: "MT001", scenicSpotId: 901, cloudFile: [MultiTravelCloudFileItem(fileId: 7001)], uploadFile: [] ) ]) XCTAssertTrue(uploader.uploadTaskFiles.isEmpty) } /// 测试任务上传本地文件先上传 OSS,上传失败不提交业务接口。 func testMultiTravelTaskUploadUploadsLocalFilesBeforeSubmit() async throws { let service = LongTailMockOrderService() service.verifiedSpotResponses = [try Self.verifiedSpots()] let uploader = LongTailMockUploader() uploader.uploadTaskFileURLs = ["https://cdn.example.com/local.jpg"] let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001") await viewModel.loadSpots(api: service) viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg") let success = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88) XCTAssertTrue(success) XCTAssertEqual(uploader.uploadTaskFiles.map(\.fileName), ["local.jpg"]) XCTAssertEqual(service.uploadMaterialRequests.first?.uploadFile, [ MultiTravelUploadFileItem(fileName: "local.jpg", fileUrl: "https://cdn.example.com/local.jpg") ]) let failingService = LongTailMockOrderService() failingService.verifiedSpotResponses = [try Self.verifiedSpots()] let failingUploader = LongTailMockUploader() failingUploader.uploadError = APIError.serverCode(500, "上传失败") let failingViewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001") await failingViewModel.loadSpots(api: failingService) failingViewModel.addLocalFile(data: Data([1]), fileName: "bad.mp4") let failure = await failingViewModel.submit(api: failingService, uploadService: failingUploader, scenicId: 88) XCTAssertFalse(failure) XCTAssertTrue(failingService.uploadMaterialRequests.isEmpty) XCTAssertEqual(failingViewModel.errorMessage, "上传失败") } /// 读取押金订单列表 fixture。 private static func depositPayload() throws -> ListPayload { try TestFixture.payload(ListPayload.self, named: "deposit_order_list_success") } /// 构建最小普通订单实体,用于退款规则测试。 private static func orderEntity(orderStatus: Int, orderType: Int, actualPayAmount: String) throws -> OrderEntity { let json = """ { "order_number": "STORE001", "order_status": "\(orderStatus)", "order_type": "\(orderType)", "actual_pay_amount": "\(actualPayAmount)", "actual_refund_amount": "0", "refund_amount": "0", "order_type_label": "旅拍订单", "order_status_name": "待退款" } """ return try JSONDecoder().decode(OrderEntity.self, from: Data(json.utf8)) } /// 读取已核销打卡点 fixture。 private static func verifiedSpots() throws -> [MultiTravelVerifiedScenicSpotItem] { try TestFixture.payload([MultiTravelVerifiedScenicSpotItem].self, named: "multi_travel_verified_spots_success") } /// 构建已核销打卡点测试实体。 private static func verifiedSpot(id: Int, name: String) throws -> MultiTravelVerifiedScenicSpotItem { let json = #"{"id":"\#(id)","name":"\#(name)"}"# return try JSONDecoder().decode(MultiTravelVerifiedScenicSpotItem.self, from: Data(json.utf8)) } } /// 长尾订单 URLSession 测试替身,按调用顺序返回响应并记录请求。 private final class LongTailRecordingURLSession: 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 ? Data(#"{"code":100000,"msg":"success"}"#.utf8) : responses.removeFirst() return ( data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! ) } } @MainActor /// 长尾订单服务测试替身,记录押金、退款和历史拍摄调用。 private final class LongTailMockOrderService: OrderServing { struct DepositListCall: Equatable { let scenicId: Int let page: Int let pageSize: Int } struct DepositRefundCall: Equatable { let orderNumber: String let reason: String } struct OrderRefundCall: Equatable { let orderNumber: String let mode: OrderRefundMode let amount: String let reason: String } var depositListResponses: [ListPayload] = [] var detailResponses: [StoreOrderDetailResponse] = [] var shootingResponses: [StoreOrderShootingDetailResponse] = [] var historyResponses: [MultiTravelShootHistoryResponse] = [] var verifiedSpotResponses: [[MultiTravelVerifiedScenicSpotItem]] = [] var depositWriteOffError: Error? var depositRefundError: Error? var orderRefundError: Error? var historyError: Error? var verifiedSpotError: Error? var uploadMaterialError: Error? private(set) var depositListCalls: [DepositListCall] = [] private(set) var depositWriteOffNumbers: [String] = [] private(set) var depositRefundCalls: [DepositRefundCall] = [] private(set) var orderRefundCalls: [OrderRefundCall] = [] private(set) var storeDetailCalls: [(storeId: Int, orderNumber: String)] = [] private(set) var storeShootingCalls: [(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int)] = [] private(set) var historyCalls: [String] = [] private(set) var verifiedSpotCalls: [String] = [] private(set) var uploadMaterialRequests: [MultiTravelUploadMaterialRequest] = [] /// 基础订单列表接口在长尾测试中不使用。 func orderList( scenicId: Int, page: Int, pageSize: Int, orderStatus: Int?, userPhone: String?, startTime: String?, endTime: String?, isRefined: Int?, isScenicAdmin: Bool ) async throws -> ListPayload { ListPayload(total: 0, list: []) } /// 核销列表接口在长尾测试中不使用。 func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { ListPayload(total: 0, list: []) } /// 普通核销接口在长尾测试中不使用。 func writeOff(orderNumber: String) async throws {} /// 返回门店订单详情。 func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { storeDetailCalls.append((storeId: storeId, orderNumber: orderNumber)) return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst() } /// 返回押金订单列表。 func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload { depositListCalls.append(.init(scenicId: scenicId, page: page, pageSize: pageSize)) return depositListResponses.isEmpty ? ListPayload(total: 0, list: []) : depositListResponses.removeFirst() } /// 记录押金核销订单号。 func depositOrderWriteOff(orderNumber: String) async throws { depositWriteOffNumbers.append(orderNumber) if let depositWriteOffError { throw depositWriteOffError } } /// 记录押金退款参数。 func depositOrderRefund(orderNumber: String, refundReason: String) async throws { depositRefundCalls.append(.init(orderNumber: orderNumber, reason: refundReason)) if let depositRefundError { throw depositRefundError } } /// 返回拍摄信息详情。 func storeOrderShootingDetail( storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int ) async throws -> StoreOrderShootingDetailResponse { storeShootingCalls.append((storeId: storeId, orderNumber: orderNumber, scenicSpotId: scenicSpotId, photogUid: photogUid)) return shootingResponses.isEmpty ? try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") : shootingResponses.removeFirst() } /// 记录普通退款参数。 func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { orderRefundCalls.append(.init(orderNumber: orderNumber, mode: refundType, amount: refundAmount, reason: refundReason)) if let orderRefundError { throw orderRefundError } } /// 返回历史拍摄信息。 func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { historyCalls.append(orderNumber) if let historyError { throw historyError } return historyResponses.isEmpty ? try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") : historyResponses.removeFirst() } /// 返回已核销打卡点列表。 func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { verifiedSpotCalls.append(orderNumber) if let verifiedSpotError { throw verifiedSpotError } return verifiedSpotResponses.isEmpty ? [] : verifiedSpotResponses.removeFirst() } /// 记录多点旅拍素材提交请求。 func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { uploadMaterialRequests.append(request) if let uploadMaterialError { throw uploadMaterialError } } } @MainActor /// 长尾订单 OSS 上传测试替身,只记录任务附件上传。 private final class LongTailMockUploader: OSSUploadServing { struct UploadTaskFileCall: Equatable { let fileName: String let fileType: Int let scenicId: Int let dataSize: Int } var uploadTaskFileURLs: [String] = [] var uploadError: Error? private(set) var uploadTaskFiles: [UploadTaskFileCall] = [] /// 返回预设任务附件 URL。 func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { uploadTaskFiles.append(.init(fileName: fileName, fileType: fileType, scenicId: scenicId, dataSize: data.count)) if let uploadError { throw uploadError } onProgress(100) return uploadTaskFileURLs.isEmpty ? "https://cdn.example.com/\(fileName)" : uploadTaskFileURLs.removeFirst() } /// 其他上传入口在本测试中不使用。 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 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 { "" } } /// 提取请求 query 方便断言。 private func longTailQueryItems(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) } }) } /// 将 JSON body 解成字典方便断言。 private func longTailBodyObject(from request: URLRequest) throws -> [String: Any] { let data = try XCTUnwrap(request.httpBody) let object = try JSONSerialization.jsonObject(with: data) return try XCTUnwrap(object as? [String: Any]) }