// // OrdersViewModelTests.swift // suixinkanTests // // Created by Codex on 2026/6/22. // import XCTest @testable import suixinkan_ios @MainActor /// 订单 ViewModel 测试,覆盖列表分页、筛选条件和手动核销状态。 final class OrdersViewModelTests: XCTestCase { /// 测试无景区时清空订单管理旧数据且不请求接口。 func testStoreOrdersClearStaleDataWhenScenicIsMissing() async throws { let api = MockOrderService() api.storeResponses = [try TestFixture.payload(ListPayload.self, named: "order_v2_success")] let viewModel = OrdersViewModel() try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) XCTAssertFalse(viewModel.storeOrders.isEmpty) try await viewModel.reloadStoreOrders(api: api, scenicId: nil, roleId: nil) XCTAssertTrue(viewModel.storeOrders.isEmpty) XCTAssertEqual(viewModel.storeTotal, 0) XCTAssertEqual(viewModel.storePage, 1) XCTAssertFalse(viewModel.storeHasMore) XCTAssertEqual(api.orderListCalls.count, 1) } /// 测试订单管理最后一页不会继续请求。 func testStoreOrdersDoNotRequestMoreAfterLastPage() async throws { let api = MockOrderService() api.storeResponses = [try TestFixture.payload(ListPayload.self, named: "order_v2_success")] let viewModel = OrdersViewModel() try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, roleId: nil) XCTAssertEqual(viewModel.storeOrders.count, 2) XCTAssertEqual(viewModel.storeTotal, 2) XCTAssertEqual(viewModel.storePage, 1) XCTAssertFalse(viewModel.storeHasMore) XCTAssertEqual(api.orderListCalls.count, 1) } /// 测试订单管理筛选参数会传入服务层并重置到第一页。 func testStoreOrderFilterUsesExpectedQueries() async throws { let api = MockOrderService() api.storeResponses = [ try TestFixture.payload(ListPayload.self, named: "order_v2_success"), try TestFixture.payload(ListPayload.self, named: "order_v2_success") ] let viewModel = OrdersViewModel() viewModel.selectedStatus = -1 viewModel.searchPhone = " 13800000000 " viewModel.filterStartDate = Self.date("2026-05-01") viewModel.filterEndDate = Self.date("2026-05-31") try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: nil) viewModel.selectedStatus = 30 viewModel.searchPhone = " " viewModel.filterStartDate = nil viewModel.filterEndDate = nil try await viewModel.reloadStoreOrders(api: api, scenicId: 88, roleId: 53) XCTAssertEqual(api.orderListCalls.count, 2) XCTAssertEqual(api.orderListCalls[0].page, 1) XCTAssertEqual(api.orderListCalls[0].isRefined, 1) XCTAssertEqual(api.orderListCalls[0].userPhone, "13800000000") XCTAssertNil(api.orderListCalls[0].orderStatus) XCTAssertEqual(api.orderListCalls[0].startTime, "2026-05-01") XCTAssertEqual(api.orderListCalls[0].endTime, "2026-05-31") XCTAssertFalse(api.orderListCalls[0].isScenicAdmin) XCTAssertEqual(api.orderListCalls[1].orderStatus, 30) XCTAssertNil(api.orderListCalls[1].isRefined) XCTAssertNil(api.orderListCalls[1].userPhone) XCTAssertTrue(api.orderListCalls[1].isScenicAdmin) XCTAssertEqual(viewModel.storePage, 1) } /// 测试无景区时清空核销订单旧数据且不请求接口。 func testWriteOffOrdersClearStaleDataWhenScenicIsMissing() async throws { let api = MockOrderService() api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] let viewModel = OrdersViewModel() try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88, storeId: 66) XCTAssertFalse(viewModel.writeOffOrders.isEmpty) try await viewModel.reloadWriteOffOrders(api: api, scenicId: nil) XCTAssertTrue(viewModel.writeOffOrders.isEmpty) XCTAssertEqual(viewModel.writeOffTotal, 0) XCTAssertEqual(viewModel.writeOffPage, 1) XCTAssertFalse(viewModel.writeOffHasMore) XCTAssertEqual(api.writeOffListCalls.count, 1) XCTAssertEqual(api.writeOffListCalls[0].storeId, 66) } /// 测试核销订单最后一页不会继续请求。 func testWriteOffOrdersDoNotRequestMoreAfterLastPage() async throws { let api = MockOrderService() api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] let viewModel = OrdersViewModel() try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) try await viewModel.loadMoreWriteOffOrders(api: api, scenicId: 88) XCTAssertEqual(viewModel.writeOffOrders.count, 1) XCTAssertEqual(viewModel.writeOffTotal, 1) XCTAssertEqual(viewModel.writeOffPage, 1) XCTAssertFalse(viewModel.writeOffHasMore) XCTAssertEqual(api.writeOffListCalls.count, 1) } /// 测试核销成功后刷新列表并重置分页状态。 func testVerifyRefreshesWriteOffOrdersAndResetsPagingState() async throws { let api = MockOrderService() api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] let viewModel = OrdersViewModel() try await viewModel.verify(api: api, scenicId: 88, storeId: 66, orderNumber: "VERIFY001") XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"]) XCTAssertEqual(api.writeOffListCalls.count, 1) XCTAssertEqual(api.writeOffListCalls[0].scenicId, 88) XCTAssertEqual(api.writeOffListCalls[0].storeId, 66) XCTAssertEqual(api.writeOffListCalls[0].page, 1) XCTAssertEqual(viewModel.writeOffOrders.count, 1) XCTAssertEqual(viewModel.writeOffPage, 1) XCTAssertFalse(viewModel.isVerifying) XCTAssertNil(viewModel.currentVerifyingOrderNumber) } /// 测试扫码命中当前核销列表时返回可高亮订单。 func testScanResultMatchesCurrentWriteOffOrder() async throws { let api = MockOrderService() api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] let viewModel = OrdersViewModel() try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "https://example.com/scan?order_number=VERIFY001")) XCTAssertEqual(result.orderNumber, "VERIFY001") XCTAssertEqual(result.matched?.orderNumber, "VERIFY001") } /// 测试扫码未命中当前列表时仍保留解析出的订单号。 func testScanResultKeepsParsedOrderNumberWhenCurrentListDoesNotContainIt() async throws { let api = MockOrderService() api.writeOffResponses = [try TestFixture.payload(ListPayload.self, named: "writeoff_list_success")] let viewModel = OrdersViewModel() try await viewModel.reloadWriteOffOrders(api: api, scenicId: 88) let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "order-number=VERIFY999")) XCTAssertEqual(result.orderNumber, "VERIFY999") XCTAssertNil(result.matched) } /// 测试重复核销提交会被忽略。 func testVerifyIgnoresDuplicateSubmitWhileAlreadyVerifying() async throws { let api = MockOrderService() let viewModel = OrdersViewModel() viewModel.markVerifyingForTests(orderNumber: "VERIFY001") try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY002") XCTAssertTrue(api.verifiedOrderNumbers.isEmpty) XCTAssertTrue(api.writeOffListCalls.isEmpty) XCTAssertTrue(viewModel.isVerifying) XCTAssertEqual(viewModel.currentVerifyingOrderNumber, "VERIFY001") } /// 测试核销失败时清理提交状态且不刷新列表。 func testVerifyFailureClearsSubmittingStateWithoutRefreshingList() async throws { let api = MockOrderService() api.verifyError = APIError.httpStatus(500, "server error") let viewModel = OrdersViewModel() do { try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001") XCTFail("Expected verify to fail") } catch APIError.httpStatus(let statusCode, _) { XCTAssertEqual(statusCode, 500) } catch { XCTFail("Unexpected error: \(error)") } XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"]) XCTAssertTrue(api.writeOffListCalls.isEmpty) XCTAssertFalse(viewModel.isVerifying) XCTAssertNil(viewModel.currentVerifyingOrderNumber) } /// 测试订单 API 会按角色选择正确接口路径。 func testOrdersAPIUsesExpectedEndpointForRole() async throws { let session = RecordingURLSession(data: try TestFixture.data(named: "order_v2_success")) let api = OrdersAPI(client: APIClient(session: session)) _ = try await api.orderList( scenicId: 88, page: 1, pageSize: 20, orderStatus: 30, userPhone: "13800000000", startTime: "2026-05-01", endTime: "2026-05-31", isRefined: nil, isScenicAdmin: true ) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.url?.path, "/api/app/scenic-admin/order/list") let query = queryItems(from: request) XCTAssertEqual(query["scenic_id"], "88") XCTAssertEqual(query["order_status"], "30") XCTAssertEqual(query["user_phone"], "13800000000") XCTAssertEqual(query["start_time"], "2026-05-01") XCTAssertEqual(query["end_time"], "2026-05-31") } private static func date(_ text: String) -> Date { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" return formatter.date(from: text)! } } @MainActor /// 订单服务测试替身,记录请求参数并返回预设响应。 private final class MockOrderService: OrderServing { struct OrderListCall: Equatable { let scenicId: Int let page: Int let pageSize: Int let orderStatus: Int? let userPhone: String? let startTime: String? let endTime: String? let isRefined: Int? let isScenicAdmin: Bool } struct WriteOffListCall: Equatable { let scenicId: Int let storeId: Int? let page: Int let pageSize: Int } var storeResponses: [ListPayload] = [] var writeOffResponses: [ListPayload] = [] var detailResponses: [StoreOrderDetailResponse] = [] var verifyError: Error? private(set) var orderListCalls: [OrderListCall] = [] private(set) var writeOffListCalls: [WriteOffListCall] = [] private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = [] private(set) var verifiedOrderNumbers: [String] = [] /// 返回测试订单管理列表。 func orderList( scenicId: Int, page: Int, pageSize: Int, orderStatus: Int?, userPhone: String?, startTime: String?, endTime: String?, isRefined: Int?, isScenicAdmin: Bool ) async throws -> ListPayload { orderListCalls.append( OrderListCall( scenicId: scenicId, page: page, pageSize: pageSize, orderStatus: orderStatus, userPhone: userPhone, startTime: startTime, endTime: endTime, isRefined: isRefined, isScenicAdmin: isScenicAdmin ) ) return storeResponses.isEmpty ? ListPayload(total: 0, list: []) : storeResponses.removeFirst() } /// 返回测试核销订单列表。 func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize)) return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst() } /// 记录测试核销订单号。 func writeOff(orderNumber: String) async throws { verifiedOrderNumbers.append(orderNumber) if let verifyError { throw verifyError } } /// 返回测试订单详情。 func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse { detailCalls.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 { XCTFail("depositOrderList should not be called") return ListPayload(total: 0, list: []) } /// 押金核销接口在基础订单测试中不会被调用。 func depositOrderWriteOff(orderNumber: String) async throws { XCTFail("depositOrderWriteOff should not be called") } /// 押金退款接口在基础订单测试中不会被调用。 func depositOrderRefund(orderNumber: String, refundReason: String) async throws { XCTFail("depositOrderRefund should not be called") } /// 拍摄信息接口在基础订单测试中不会被调用。 func storeOrderShootingDetail( storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int ) async throws -> StoreOrderShootingDetailResponse { XCTFail("storeOrderShootingDetail should not be called") return try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") } /// 普通退款接口在基础订单测试中不会被调用。 func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws { XCTFail("orderRefund should not be called") } /// 历史拍摄接口在基础订单测试中不会被调用。 func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse { XCTFail("multiTravelShootHistory should not be called") return try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") } /// 已核销打卡点接口在基础订单测试中不会被调用。 func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { XCTFail("multiTravelVerifiedScenicSpotList should not be called") return [] } /// 多点旅拍素材提交接口在基础订单测试中不会被调用。 func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws { XCTFail("multiTravelUploadMaterial should not be called") } } /// URLSession 测试替身,记录请求并返回固定响应。 private final class RecordingURLSession: URLSessionProtocol { let data: Data var statusCode: Int private(set) var requests: [URLRequest] = [] init(data: Data, statusCode: Int = 200) { self.data = data self.statusCode = statusCode } /// 返回固定数据和 HTTP 响应。 func data(for request: URLRequest) async throws -> (Data, URLResponse) { requests.append(request) return ( data, HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! ) } } 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) } }) }