Files
suixinkan_ios_new/suixinkanTests/OrdersViewModelTests.swift
汉秋 1970572edd 迁移合作订单完整能力,并统一 AppRoleCode 角色权限与首页菜单展示。
新增 CooperationOrder 模块(双 Tab 列表、获客员绑定、主 Tab 扫码)、订单来源/带客单绑定及配套 API 测试;同步引入 roleCode 权限匹配与相关单元测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 13:33:00 +08:00

393 lines
16 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// OrdersViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/22.
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class OrdersViewModelTests: XCTestCase {
///
func testStoreOrdersClearStaleDataWhenScenicIsMissing() async throws {
let api = MockOrderService()
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
let viewModel = OrdersViewModel()
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
XCTAssertFalse(viewModel.storeOrders.isEmpty)
try await viewModel.reloadStoreOrders(api: api, scenicId: nil, appRole: 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<OrderEntity>.self, named: "order_v2_success")]
let viewModel = OrdersViewModel()
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
try await viewModel.loadMoreStoreOrders(api: api, scenicId: 88, appRole: 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<OrderEntity>.self, named: "order_v2_success"),
try TestFixture.payload(ListPayload<OrderEntity>.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, appRole: nil)
viewModel.selectedStatus = 30
viewModel.searchPhone = " "
viewModel.filterStartDate = nil
viewModel.filterEndDate = nil
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: .scenicAdmin)
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<WriteOffOrderItem>.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<WriteOffOrderItem>.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<WriteOffOrderItem>.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<WriteOffOrderItem>.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<WriteOffOrderItem>.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<OrderEntity>] = []
var writeOffResponses: [ListPayload<WriteOffOrderItem>] = []
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<OrderEntity> {
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<WriteOffOrderItem> {
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<DepositOrderListItem> {
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) }
})
}