同步订单详情与扫码核销流程
This commit is contained in:
42
suixinkanTests/Fixtures/store_order_detail_success.json
Normal file
42
suixinkanTests/Fixtures/store_order_detail_success.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"order_number": "ORDV2001",
|
||||
"order_type": "19",
|
||||
"order_type_label": "多点旅拍",
|
||||
"created_at": "2026-05-23 04:39:00",
|
||||
"order_status": "20",
|
||||
"order_status_name": "已支付",
|
||||
"actual_pay_amount": "299.50",
|
||||
"actual_refund_amount": 0,
|
||||
"pay_type_name": "微信支付",
|
||||
"pay_time": "2026-05-23 04:40:00",
|
||||
"complete_time": "",
|
||||
"user_id": "7001",
|
||||
"phone": "13800000000",
|
||||
"project_id": "5101",
|
||||
"project_name": "亲子跟拍",
|
||||
"remark": "需要精修",
|
||||
"multi_travel": {
|
||||
"project_info": {
|
||||
"settle_spot_num": "3",
|
||||
"single_spot_material_num": 8,
|
||||
"single_spot_photo_num": "2",
|
||||
"single_spot_video_num": 1
|
||||
},
|
||||
"shooting_list": [
|
||||
{
|
||||
"scenic_spot_id": "901",
|
||||
"photog_uid": "42",
|
||||
"scenic_spot_name": "东门",
|
||||
"staff_name": "",
|
||||
"photog_name": "张三",
|
||||
"status": "2",
|
||||
"start_avg": "4.8",
|
||||
"start": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,8 @@ final class NavigationRouterTests: XCTestCase {
|
||||
func testPushedRoutesHideTabBarByDefault() {
|
||||
XCTAssertTrue(AppRoute.placeholder(title: "详情").hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.home(.moreFunctions).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
|
||||
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
|
||||
}
|
||||
|
||||
/// 测试切换订单 Tab 时会保存订单内部子入口。
|
||||
@ -25,4 +27,30 @@ final class NavigationRouterTests: XCTestCase {
|
||||
XCTAssertEqual(router.selectedTab, .orders)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||
}
|
||||
|
||||
/// 测试订单模块真实路由可被压入导航栈。
|
||||
func testOrderRoutesCanBePushedIntoRouterPath() throws {
|
||||
let router = RouterPath()
|
||||
let order = try Self.firstOrder()
|
||||
let writeOff = try Self.firstWriteOffOrder()
|
||||
|
||||
router.navigate(to: .orders(.storeDetail(order)))
|
||||
router.navigate(to: .orders(.writeOffDetail(writeOff)))
|
||||
|
||||
XCTAssertEqual(router.path.count, 2)
|
||||
XCTAssertEqual(router.path[0], .orders(.storeDetail(order)))
|
||||
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
|
||||
}
|
||||
|
||||
/// 返回测试用订单管理列表项。
|
||||
private static func firstOrder() throws -> OrderEntity {
|
||||
let payload = try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
|
||||
return try XCTUnwrap(payload.list.first)
|
||||
}
|
||||
|
||||
/// 返回测试用核销订单列表项。
|
||||
private static func firstWriteOffOrder() throws -> WriteOffOrderItem {
|
||||
let payload = try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")
|
||||
return try XCTUnwrap(payload.list.first)
|
||||
}
|
||||
}
|
||||
|
||||
120
suixinkanTests/OrderDetailViewModelTests.swift
Normal file
120
suixinkanTests/OrderDetailViewModelTests.swift
Normal file
@ -0,0 +1,120 @@
|
||||
//
|
||||
// OrderDetailViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 订单详情 ViewModel 测试,覆盖详情加载、缺失上下文和错误兜底。
|
||||
final class OrderDetailViewModelTests: XCTestCase {
|
||||
/// 测试有门店 ID 时请求详情接口并展示服务端字段。
|
||||
func testLoadRequestsDetailWhenStoreIdExists() async throws {
|
||||
let item = try firstOrder()
|
||||
let api = DetailMockOrderService()
|
||||
api.detailResponses = [try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")]
|
||||
let viewModel = OrderDetailViewModel(item: item)
|
||||
|
||||
await viewModel.load(api: api, fallbackStoreId: 66)
|
||||
|
||||
XCTAssertEqual(api.detailCalls.count, 1)
|
||||
XCTAssertEqual(api.detailCalls[0].storeId, 66)
|
||||
XCTAssertEqual(api.detailCalls[0].orderNumber, "ORDV2001")
|
||||
XCTAssertEqual(viewModel.display.projectName, "亲子跟拍")
|
||||
XCTAssertEqual(viewModel.projectInfo?.settleSpotNum, 3)
|
||||
XCTAssertEqual(viewModel.shootingList.first?.scenicSpotName, "东门")
|
||||
XCTAssertNil(viewModel.errorMessage)
|
||||
}
|
||||
|
||||
/// 测试缺少门店 ID 时不请求接口并保留列表摘要。
|
||||
func testLoadWithoutStoreIdUsesSummaryOnly() async throws {
|
||||
let item = try firstOrder()
|
||||
let api = DetailMockOrderService()
|
||||
let viewModel = OrderDetailViewModel(item: item)
|
||||
|
||||
await viewModel.load(api: api, fallbackStoreId: nil)
|
||||
|
||||
XCTAssertTrue(api.detailCalls.isEmpty)
|
||||
XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001")
|
||||
XCTAssertEqual(viewModel.contextMessage, "缺少门店上下文,当前展示订单摘要。")
|
||||
}
|
||||
|
||||
/// 测试详情接口失败时保留摘要并暴露错误信息。
|
||||
func testLoadFailureKeepsSummaryAndExposesError() async throws {
|
||||
let item = try firstOrder()
|
||||
let api = DetailMockOrderService()
|
||||
api.detailError = APIError.httpStatus(500, "server error")
|
||||
let viewModel = OrderDetailViewModel(item: item)
|
||||
|
||||
await viewModel.load(api: api, fallbackStoreId: 66)
|
||||
|
||||
XCTAssertEqual(api.detailCalls.count, 1)
|
||||
XCTAssertEqual(viewModel.display.orderNumber, "ORDV2001")
|
||||
XCTAssertEqual(viewModel.contextMessage, "详情加载失败,当前展示订单摘要。")
|
||||
XCTAssertNotNil(viewModel.errorMessage)
|
||||
}
|
||||
|
||||
/// 测试详情模型兼容后端数字和字符串混合字段。
|
||||
func testDetailResponseDecodesMixedNumericAndStringFields() throws {
|
||||
let detail = try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
||||
|
||||
XCTAssertEqual(detail.orderType, 19)
|
||||
XCTAssertEqual(detail.actualRefundAmount, "0")
|
||||
XCTAssertEqual(detail.userId, 7001)
|
||||
XCTAssertEqual(detail.multiTravel?.projectInfo?.singleSpotVideoNum, 1)
|
||||
XCTAssertEqual(detail.multiTravel?.shootingList.first?.startAvg, 4.8)
|
||||
}
|
||||
|
||||
/// 返回订单列表 fixture 中的第一条订单。
|
||||
private func firstOrder() throws -> OrderEntity {
|
||||
let payload = try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
|
||||
return try XCTUnwrap(payload.list.first)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
/// 订单详情服务测试替身,专门用于详情 ViewModel 测试。
|
||||
private final class DetailMockOrderService: OrderServing {
|
||||
var detailResponses: [StoreOrderDetailResponse] = []
|
||||
var detailError: Error?
|
||||
private(set) var detailCalls: [(storeId: Int, orderNumber: 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> {
|
||||
XCTFail("orderList should not be called")
|
||||
return ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 核销列表接口在详情测试中不会被调用。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
XCTFail("writeOffList should not be called")
|
||||
return ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 核销接口在详情测试中不会被调用。
|
||||
func writeOff(orderNumber: String) async throws {
|
||||
XCTFail("writeOff should not be called")
|
||||
}
|
||||
|
||||
/// 返回预置的订单详情响应。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
detailCalls.append((storeId: storeId, orderNumber: orderNumber))
|
||||
if let detailError {
|
||||
throw detailError
|
||||
}
|
||||
return detailResponses.isEmpty ? try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success") : detailResponses.removeFirst()
|
||||
}
|
||||
}
|
||||
38
suixinkanTests/OrderNumberParserTests.swift
Normal file
38
suixinkanTests/OrderNumberParserTests.swift
Normal file
@ -0,0 +1,38 @@
|
||||
//
|
||||
// OrderNumberParserTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 订单号解析器测试,覆盖扫码内容中的常见订单号格式。
|
||||
final class OrderNumberParserTests: XCTestCase {
|
||||
/// 测试从 URL query 中解析 order_number。
|
||||
func testParseOrderNumberFromURLQuery() {
|
||||
XCTAssertEqual(
|
||||
OrderNumberParser.parse("https://example.com/check?foo=1&order_number=ORD123456"),
|
||||
"ORD123456"
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试从 key-value 文本中解析订单号。
|
||||
func testParseOrderNumberFromKeyValueText() {
|
||||
XCTAssertEqual(OrderNumberParser.parse("order_number=ORD123456"), "ORD123456")
|
||||
XCTAssertEqual(OrderNumberParser.parse("order-number:ORD654321"), "ORD654321")
|
||||
}
|
||||
|
||||
/// 测试从纯文本中解析订单号。
|
||||
func testParsePlainOrderNumberText() {
|
||||
XCTAssertEqual(OrderNumberParser.parse(" VERIFY001 "), "VERIFY001")
|
||||
}
|
||||
|
||||
/// 测试无效内容不会返回订单号。
|
||||
func testParseInvalidTextReturnsNil() {
|
||||
XCTAssertNil(OrderNumberParser.parse(""))
|
||||
XCTAssertNil(OrderNumberParser.parse("abc def"))
|
||||
XCTAssertNil(OrderNumberParser.parse("12345"))
|
||||
}
|
||||
}
|
||||
@ -87,7 +87,7 @@ final class OrdersViewModelTests: XCTestCase {
|
||||
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.reloadWriteOffOrders(api: api, scenicId: 88, storeId: 66)
|
||||
XCTAssertFalse(viewModel.writeOffOrders.isEmpty)
|
||||
|
||||
try await viewModel.reloadWriteOffOrders(api: api, scenicId: nil)
|
||||
@ -97,6 +97,7 @@ final class OrdersViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(viewModel.writeOffPage, 1)
|
||||
XCTAssertFalse(viewModel.writeOffHasMore)
|
||||
XCTAssertEqual(api.writeOffListCalls.count, 1)
|
||||
XCTAssertEqual(api.writeOffListCalls[0].storeId, 66)
|
||||
}
|
||||
|
||||
/// 测试核销订单最后一页不会继续请求。
|
||||
@ -121,11 +122,12 @@ final class OrdersViewModelTests: XCTestCase {
|
||||
api.writeOffResponses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = OrdersViewModel()
|
||||
|
||||
try await viewModel.verify(api: api, scenicId: 88, orderNumber: "VERIFY001")
|
||||
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)
|
||||
@ -133,6 +135,32 @@ final class OrdersViewModelTests: XCTestCase {
|
||||
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()
|
||||
@ -220,15 +248,18 @@ private final class MockOrderService: OrderServing {
|
||||
|
||||
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] = []
|
||||
|
||||
/// 返回测试订单管理列表。
|
||||
@ -260,8 +291,8 @@ private final class MockOrderService: OrderServing {
|
||||
}
|
||||
|
||||
/// 返回测试核销订单列表。
|
||||
func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, page: page, pageSize: pageSize))
|
||||
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()
|
||||
}
|
||||
|
||||
@ -272,6 +303,12 @@ private final class MockOrderService: OrderServing {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// URLSession 测试替身,记录请求并返回固定响应。
|
||||
|
||||
Reference in New Issue
Block a user