Initial commit: suixinkan_ios UIKit rewrite project.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
392
suixinkan_iosTests/OrdersViewModelTests.swift
Normal file
392
suixinkan_iosTests/OrdersViewModelTests.swift
Normal file
@ -0,0 +1,392 @@
|
||||
//
|
||||
// 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<OrderEntity>.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<OrderEntity>.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<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, 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<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) }
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user