新增门店订单与核销管理列表,对齐 Android 订单能力并完善账号级缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -11,6 +11,18 @@ import XCTest
|
||||
@MainActor
|
||||
/// 账号上下文测试,覆盖景区、门店和角色权限的选择恢复。
|
||||
final class AccountContextTests: XCTestCase {
|
||||
/// 测试账号缓存前缀与 Android 的账号作用域规则一致。
|
||||
func testAccountCachePrefixUsesAccountTypeAndUserId() {
|
||||
let context = AccountContext()
|
||||
|
||||
context.applyLogin(
|
||||
profile: AccountProfile(userId: "101", displayName: "测试账号"),
|
||||
accountType: "store_user"
|
||||
)
|
||||
|
||||
XCTAssertEqual(context.accountCachePrefix, "store_user_101")
|
||||
}
|
||||
|
||||
/// 测试 replaceScopes 能按缓存 ID 恢复当前景区和门店。
|
||||
func testReplaceScopesRestoresCurrentSelectionsById() {
|
||||
let context = AccountContext()
|
||||
|
||||
45
suixinkanTests/Fixtures/store_order_list_success.json
Normal file
45
suixinkanTests/Fixtures/store_order_list_success.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": "2",
|
||||
"list": [
|
||||
{
|
||||
"order_number": "STORE001",
|
||||
"order_type": "19",
|
||||
"order_type_label": "多点旅拍",
|
||||
"created_at": "2026-05-23 09:00:00",
|
||||
"order_status": "18",
|
||||
"order_status_name": "已付定金",
|
||||
"actual_pay_amount": "199.00",
|
||||
"actual_refund_amount": "0",
|
||||
"pay_type_name": "微信支付",
|
||||
"pay_time": "2026-05-23 09:01:00",
|
||||
"complete_time": "",
|
||||
"user_id": "8001",
|
||||
"phone": "13800000000",
|
||||
"project_id": "6001",
|
||||
"project_name": "店长套餐",
|
||||
"remark": ""
|
||||
},
|
||||
{
|
||||
"order_number": "STORE002",
|
||||
"order_type": 4,
|
||||
"order_type_label": "线下产品",
|
||||
"created_at": "2026-05-22 18:30:00",
|
||||
"order_status": 30,
|
||||
"order_status_name": "已完成",
|
||||
"actual_pay_amount": "88.00",
|
||||
"actual_refund_amount": "0",
|
||||
"pay_type_name": "余额支付",
|
||||
"pay_time": "2026-05-22 18:31:00",
|
||||
"complete_time": "2026-05-22 19:00:00",
|
||||
"user_id": 8002,
|
||||
"phone": "13900000000",
|
||||
"project_id": 6002,
|
||||
"project_name": "线下体验",
|
||||
"remark": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -76,7 +76,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management"])
|
||||
}
|
||||
|
||||
/// 测试已保存 URI 会按当前权限过滤并解析同义 URI。
|
||||
/// 测试已保存 URI 会按当前权限和 Android 白名单过滤并解析同义 URI。
|
||||
func testLoadFiltersSavedUrisByCurrentPermissions() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")
|
||||
@ -92,7 +92,41 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
topLevelPermissionURIs: ["task_management_editor", "project_edit"]
|
||||
)
|
||||
|
||||
XCTAssertEqual(uris, ["task_management_editor", "project_edit"])
|
||||
XCTAssertEqual(uris, ["task_management_editor"])
|
||||
}
|
||||
|
||||
/// 测试同一角色不同账号使用独立的常用应用缓存。
|
||||
func testLoadUsesAccountScopedStorageKeys() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
defaults.set(
|
||||
["wallet"],
|
||||
forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_101")
|
||||
)
|
||||
defaults.set(
|
||||
["cloud_management"],
|
||||
forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "store_user_101")
|
||||
)
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("wallet"),
|
||||
menu("cloud_management")
|
||||
]
|
||||
|
||||
let scenic = store.load(
|
||||
menuItems: menus,
|
||||
roleCode: "photographer",
|
||||
accountScope: "scenic_user_101",
|
||||
topLevelPermissionURIs: ["wallet", "cloud_management"]
|
||||
)
|
||||
let storeAccount = store.load(
|
||||
menuItems: menus,
|
||||
roleCode: "photographer",
|
||||
accountScope: "store_user_101",
|
||||
topLevelPermissionURIs: ["wallet", "cloud_management"]
|
||||
)
|
||||
|
||||
XCTAssertEqual(scenic, ["wallet"])
|
||||
XCTAssertEqual(storeAccount, ["cloud_management"])
|
||||
}
|
||||
|
||||
/// 测试不同角色使用独立存储 key。
|
||||
@ -183,6 +217,94 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "46"))
|
||||
}
|
||||
|
||||
/// 测试旧 roleCode key 会迁移到账号作用域 key。
|
||||
func testLoadMigratesLegacyRoleCodeStorageKeyToAccountScopedKey() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
defaults.set(["wallet", "cloud_management"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [menu("wallet"), menu("cloud_management")]
|
||||
|
||||
let uris = store.load(
|
||||
menuItems: menus,
|
||||
roleCode: "photographer",
|
||||
accountScope: "scenic_user_101",
|
||||
topLevelPermissionURIs: ["wallet", "cloud_management"]
|
||||
)
|
||||
|
||||
XCTAssertEqual(uris, ["wallet", "cloud_management"])
|
||||
XCTAssertEqual(
|
||||
defaults.stringArray(forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_101")),
|
||||
["wallet", "cloud_management"]
|
||||
)
|
||||
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer"))
|
||||
}
|
||||
|
||||
/// 测试 Android 白名单外的 URI 不会进入默认常用应用。
|
||||
func testDefaultCommonURIsExcludeMenusMissingFromAndroidCatalog() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("wallet"),
|
||||
menu("album_list"),
|
||||
menu("cloud_storage_transit"),
|
||||
menu("message_center")
|
||||
]
|
||||
|
||||
let uris = store.load(
|
||||
menuItems: menus,
|
||||
roleCode: "photographer",
|
||||
accountScope: "scenic_user_101",
|
||||
topLevelPermissionURIs: ["wallet", "album_list", "cloud_storage_transit", "message_center"]
|
||||
)
|
||||
|
||||
XCTAssertEqual(uris, ["wallet", "message_center"])
|
||||
}
|
||||
|
||||
/// 测试已保存常用应用超过 4 个时只过滤权限,不会被强制截断。
|
||||
func testSavedCommonURIsAreNotTruncatedToFour() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
defaults.set(
|
||||
["space_settings", "wallet", "cloud_management", "asset_management", "message_center"],
|
||||
forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_101")
|
||||
)
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("space_settings"),
|
||||
menu("wallet"),
|
||||
menu("cloud_management"),
|
||||
menu("asset_management"),
|
||||
menu("message_center")
|
||||
]
|
||||
|
||||
let uris = store.load(
|
||||
menuItems: menus,
|
||||
roleCode: "photographer",
|
||||
accountScope: "scenic_user_101",
|
||||
topLevelPermissionURIs: menus.map(\.uri)
|
||||
)
|
||||
|
||||
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "asset_management", "message_center"])
|
||||
}
|
||||
|
||||
/// 测试默认常用只取顶层权限,不会用子权限补位。
|
||||
func testDefaultCommonURIsUseOnlyTopLevelPermissions() {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("task_management"),
|
||||
menu("message_center")
|
||||
]
|
||||
|
||||
let uris = store.load(
|
||||
menuItems: menus,
|
||||
roleCode: "photographer",
|
||||
accountScope: "scenic_user_101",
|
||||
topLevelPermissionURIs: ["task_management"]
|
||||
)
|
||||
|
||||
XCTAssertEqual(uris, ["task_management"])
|
||||
}
|
||||
|
||||
/// 创建测试菜单实体。
|
||||
private func menu(_ uri: String) -> HomeMenuItem {
|
||||
HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)
|
||||
|
||||
@ -73,7 +73,7 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
func testTabRoutesStayCentralized() {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .orders(.storeOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .orders(.storeOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orders(.verificationOrders))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orderPush(.writeOffList))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics))
|
||||
}
|
||||
|
||||
|
||||
@ -88,7 +88,17 @@ private final class MockMainTabOrderService: OrderServing {
|
||||
}
|
||||
|
||||
/// 记录核销列表请求并返回预设响应。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
func writeOffList(
|
||||
scenicId: Int,
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
orderVerifyStatus: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
userPhone: String?
|
||||
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
writeOffListCalls.append(.init(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
|
||||
if let writeOffError {
|
||||
throw writeOffError
|
||||
@ -96,6 +106,29 @@ private final class MockMainTabOrderService: OrderServing {
|
||||
return writeOffResponse
|
||||
}
|
||||
|
||||
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
|
||||
func cancelOrder(orderNumber: String) async throws {}
|
||||
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
|
||||
func storeOrderList(
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
userPhone: String?,
|
||||
orderStatus: Int?,
|
||||
orderType: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?
|
||||
) async throws -> ListPayload<StoreOrderListItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
func storeOrderRefund(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
refundType: OrderRefundMode,
|
||||
refundAmount: String,
|
||||
refundReason: String
|
||||
) async throws {}
|
||||
|
||||
/// 角标测试不会触发核销操作。
|
||||
func writeOff(orderNumber: String) async throws {}
|
||||
|
||||
|
||||
@ -30,14 +30,14 @@ final class NavigationRouterTests: XCTestCase {
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||
}
|
||||
|
||||
/// 测试全局扫码核销路由会切到订单核销入口并保存扫码结果。
|
||||
/// 测试全局扫码核销路由会切到核销管理页并保存扫码结果。
|
||||
func testRouteToOrderVerificationStoresPendingScanCode() {
|
||||
let router = AppRouter()
|
||||
|
||||
router.routeToOrderVerification(scannedCode: "order_number=VERIFY001")
|
||||
|
||||
XCTAssertEqual(router.selectedTab, .orders)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||
XCTAssertEqual(router.router(for: .orders).path.last, .orders(.writeOffList))
|
||||
XCTAssertEqual(router.pendingOrderScanCode, "order_number=VERIFY001")
|
||||
}
|
||||
|
||||
|
||||
@ -447,10 +447,43 @@ private final class LongTailMockOrderService: OrderServing {
|
||||
}
|
||||
|
||||
/// 核销列表接口在长尾测试中不使用。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
func writeOffList(
|
||||
scenicId: Int,
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
orderVerifyStatus: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
userPhone: String?
|
||||
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
|
||||
func cancelOrder(orderNumber: String) async throws {}
|
||||
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
|
||||
func storeOrderList(
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
userPhone: String?,
|
||||
orderStatus: Int?,
|
||||
orderType: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?
|
||||
) async throws -> ListPayload<StoreOrderListItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
func storeOrderRefund(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
refundType: OrderRefundMode,
|
||||
refundAmount: String,
|
||||
refundReason: String
|
||||
) async throws {}
|
||||
|
||||
/// 普通核销接口在长尾测试中不使用。
|
||||
func writeOff(orderNumber: String) async throws {}
|
||||
|
||||
|
||||
@ -223,6 +223,93 @@ final class OrdersViewModelTests: XCTestCase {
|
||||
XCTAssertEqual(query["end_time"], "2026-05-31")
|
||||
}
|
||||
|
||||
/// 测试精修选择更新会同步本地列表状态。
|
||||
func testUpdateRefinedChoiceUpdatesLocalOrder() 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.updateRefinedChoice(api: api, orderNumber: "ORDV2001", isRefined: 0)
|
||||
|
||||
XCTAssertEqual(api.refinedUpdates.count, 1)
|
||||
XCTAssertEqual(api.refinedUpdates[0].0, "ORDV2001")
|
||||
XCTAssertEqual(api.refinedUpdates[0].1, 0)
|
||||
let order = try XCTUnwrap(viewModel.storeOrders.first { $0.orderNumber == "ORDV2001" })
|
||||
XCTAssertEqual(order.isRefined, 0)
|
||||
}
|
||||
|
||||
/// 测试取消订单会调用接口并刷新列表。
|
||||
func testCancelOrderRefreshesStoreOrders() 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()
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
|
||||
|
||||
try await viewModel.cancelOrder(api: api, scenicId: 88, appRole: nil, orderNumber: "ORDV2001")
|
||||
|
||||
XCTAssertEqual(api.cancelledOrders, ["ORDV2001"])
|
||||
XCTAssertEqual(api.orderListCalls.count, 2)
|
||||
}
|
||||
|
||||
/// 测试赠送弹窗上下文与提交流程。
|
||||
func testSendGiftFlowPreparesAndSubmits() 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()
|
||||
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
|
||||
let order = try XCTUnwrap(viewModel.storeOrders.first)
|
||||
|
||||
viewModel.prepareSendGift(for: order)
|
||||
XCTAssertEqual(viewModel.sendGiftContext?.orderNumber, order.orderNumber)
|
||||
XCTAssertEqual(viewModel.sendGiftContext?.sendPhotoNum, 2)
|
||||
|
||||
try await viewModel.submitSendGift(api: api, scenicId: 88, appRole: nil, photoNum: 3, videoNum: 1)
|
||||
|
||||
XCTAssertEqual(api.sendGiftCalls.count, 1)
|
||||
XCTAssertEqual(api.sendGiftCalls[0].0, order.orderNumber)
|
||||
XCTAssertEqual(api.sendGiftCalls[0].1, 3)
|
||||
XCTAssertEqual(api.sendGiftCalls[0].2, 1)
|
||||
XCTAssertNil(viewModel.sendGiftContext)
|
||||
XCTAssertEqual(api.orderListCalls.count, 2)
|
||||
}
|
||||
|
||||
/// 测试退款按钮展示条件。
|
||||
func testShouldShowRefundButtonConditions() 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)
|
||||
|
||||
let multiTravel = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 19 })
|
||||
let deposit = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 4 })
|
||||
|
||||
XCTAssertFalse(viewModel.shouldShowRefundButton(order: multiTravel, appRole: nil))
|
||||
XCTAssertTrue(viewModel.shouldShowRefundButton(order: deposit, appRole: nil))
|
||||
XCTAssertFalse(viewModel.shouldShowRefundButton(order: deposit, appRole: .scenicAdmin))
|
||||
}
|
||||
|
||||
/// 测试核销按钮展示条件。
|
||||
func testShouldShowVerifyButtonConditions() 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)
|
||||
|
||||
let multiTravel = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 19 })
|
||||
let deposit = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 4 })
|
||||
|
||||
XCTAssertTrue(viewModel.shouldShowVerifyButton(order: multiTravel, appRole: nil))
|
||||
XCTAssertFalse(viewModel.shouldShowVerifyButton(order: deposit, appRole: nil))
|
||||
XCTAssertFalse(viewModel.shouldShowVerifyButton(order: multiTravel, appRole: .scenicAdmin))
|
||||
}
|
||||
|
||||
private static func date(_ text: String) -> Date {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
@ -261,6 +348,9 @@ private final class MockOrderService: OrderServing {
|
||||
private(set) var writeOffListCalls: [WriteOffListCall] = []
|
||||
private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = []
|
||||
private(set) var verifiedOrderNumbers: [String] = []
|
||||
private(set) var refinedUpdates: [(String, Int)] = []
|
||||
private(set) var cancelledOrders: [String] = []
|
||||
private(set) var sendGiftCalls: [(String, Int, Int)] = []
|
||||
|
||||
/// 返回测试订单管理列表。
|
||||
func orderList(
|
||||
@ -291,11 +381,50 @@ private final class MockOrderService: OrderServing {
|
||||
}
|
||||
|
||||
/// 返回测试核销订单列表。
|
||||
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
func writeOffList(
|
||||
scenicId: Int,
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
orderVerifyStatus: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
userPhone: String?
|
||||
) 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 updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {
|
||||
refinedUpdates.append((orderNumber, isRefined))
|
||||
}
|
||||
func cancelOrder(orderNumber: String) async throws {
|
||||
cancelledOrders.append(orderNumber)
|
||||
}
|
||||
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {
|
||||
sendGiftCalls.append((orderNumber, photoNum, videoNum))
|
||||
}
|
||||
func storeOrderList(
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
userPhone: String?,
|
||||
orderStatus: Int?,
|
||||
orderType: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?
|
||||
) async throws -> ListPayload<StoreOrderListItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
func storeOrderRefund(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
refundType: OrderRefundMode,
|
||||
refundAmount: String,
|
||||
refundReason: String
|
||||
) async throws {}
|
||||
|
||||
/// 记录测试核销订单号。
|
||||
func writeOff(orderNumber: String) async throws {
|
||||
verifiedOrderNumbers.append(orderNumber)
|
||||
|
||||
@ -171,6 +171,35 @@ final class ProfileSecondaryPagesTests: XCTestCase {
|
||||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
||||
}
|
||||
|
||||
/// 测试接口返回前使用本地缓存展示昵称、手机号和头像。
|
||||
func testProfileDisplayFieldsUseCachedProfileBeforeUserInfoLoads() {
|
||||
let viewModel = ProfileViewModel()
|
||||
viewModel.applyCachedProfile(
|
||||
AccountProfile(
|
||||
userId: "101",
|
||||
displayName: "缓存昵称",
|
||||
phone: "13800000000",
|
||||
avatarURL: "https://cdn.example.com/avatar.jpg"
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
|
||||
XCTAssertEqual(viewModel.displayPhone, "13800000000")
|
||||
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/avatar.jpg")
|
||||
}
|
||||
|
||||
/// 测试接口数据已存在时不再被本地缓存覆盖。
|
||||
func testApplyCachedProfileIgnoredAfterUserInfoLoaded() {
|
||||
let viewModel = ProfileViewModel()
|
||||
viewModel.userInfo = UserInfoResponse(nickname: "接口昵称", phone: "13900000000")
|
||||
viewModel.applyCachedProfile(
|
||||
AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000")
|
||||
)
|
||||
|
||||
XCTAssertEqual(viewModel.displayNickname, "接口昵称")
|
||||
XCTAssertEqual(viewModel.displayPhone, "13900000000")
|
||||
}
|
||||
|
||||
/// 测试头像变更时先上传 OSS,再回写头像 URL。
|
||||
func testProfileAvatarUploadUpdatesAvatarURL() async throws {
|
||||
let session = ProfileSecondaryURLSession(responses: [
|
||||
|
||||
@ -94,7 +94,7 @@ final class PushNotificationTests: XCTestCase {
|
||||
|
||||
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
|
||||
XCTAssertEqual(router.selectedTab, .orders)
|
||||
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
|
||||
XCTAssertEqual(router.router(for: .orders).path.last, .orders(.writeOffList))
|
||||
}
|
||||
|
||||
private func pushManager(router: AppRouter) -> PushNotificationManager {
|
||||
|
||||
224
suixinkanTests/StoreAdminOrderListViewModelTests.swift
Normal file
224
suixinkanTests/StoreAdminOrderListViewModelTests.swift
Normal file
@ -0,0 +1,224 @@
|
||||
//
|
||||
// StoreAdminOrderListViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 店长订单 Tab ViewModel 测试,覆盖列表分页、筛选与退款参数。
|
||||
final class StoreAdminOrderListViewModelTests: XCTestCase {
|
||||
/// 测试无门店时清空列表且不请求接口。
|
||||
func testReloadClearsDataWhenStoreIsMissing() async throws {
|
||||
let api = StoreAdminMockOrderService()
|
||||
api.responses = [try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success")]
|
||||
let viewModel = StoreAdminOrderListViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, storeId: 66)
|
||||
XCTAssertFalse(viewModel.orders.isEmpty)
|
||||
|
||||
try await viewModel.reload(api: api, storeId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.orders.isEmpty)
|
||||
XCTAssertEqual(viewModel.total, 0)
|
||||
XCTAssertEqual(viewModel.page, 1)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
XCTAssertEqual(api.listCalls.count, 1)
|
||||
}
|
||||
|
||||
/// 测试筛选参数会传入服务层并重置到第一页。
|
||||
func testReloadUsesFilterQueries() async throws {
|
||||
let api = StoreAdminMockOrderService()
|
||||
api.responses = [
|
||||
try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success"),
|
||||
try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success")
|
||||
]
|
||||
let viewModel = StoreAdminOrderListViewModel()
|
||||
viewModel.searchPhone = " 13800000000 "
|
||||
viewModel.selectedStatus = 30
|
||||
viewModel.filterStartDate = Self.date("2026-05-01")
|
||||
viewModel.filterEndDate = Self.date("2026-05-31")
|
||||
|
||||
try await viewModel.reload(api: api, storeId: 66)
|
||||
XCTAssertEqual(api.listCalls.count, 1)
|
||||
XCTAssertEqual(api.listCalls[0].storeId, 66)
|
||||
XCTAssertEqual(api.listCalls[0].userPhone, "13800000000")
|
||||
XCTAssertEqual(api.listCalls[0].orderStatus, 30)
|
||||
XCTAssertEqual(api.listCalls[0].startTime, "2026-05-01")
|
||||
XCTAssertEqual(api.listCalls[0].endTime, "2026-05-31")
|
||||
|
||||
viewModel.selectedStatus = 0
|
||||
viewModel.searchPhone = ""
|
||||
try await viewModel.reload(api: api, storeId: 66)
|
||||
XCTAssertEqual(api.listCalls[1].orderStatus, nil)
|
||||
XCTAssertNil(api.listCalls[1].userPhone)
|
||||
}
|
||||
|
||||
/// 测试最后一页不会继续请求。
|
||||
func testLoadMoreStopsAfterLastPage() async throws {
|
||||
let api = StoreAdminMockOrderService()
|
||||
api.responses = [try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success")]
|
||||
let viewModel = StoreAdminOrderListViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, storeId: 66)
|
||||
try await viewModel.loadMore(api: api, storeId: 66)
|
||||
|
||||
XCTAssertEqual(viewModel.orders.count, 2)
|
||||
XCTAssertEqual(api.listCalls.count, 1)
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
}
|
||||
|
||||
/// 测试店长退款会传入门店与订单参数。
|
||||
func testRefundUsesStoreParameters() async throws {
|
||||
let api = StoreAdminMockOrderService()
|
||||
let viewModel = StoreAdminOrderListViewModel()
|
||||
|
||||
try await viewModel.refund(
|
||||
api: api,
|
||||
storeId: 66,
|
||||
orderNumber: "STORE001",
|
||||
mode: .partial,
|
||||
amount: "50.00",
|
||||
reason: "部分退款"
|
||||
)
|
||||
|
||||
XCTAssertEqual(api.refundCalls.count, 1)
|
||||
XCTAssertEqual(api.refundCalls[0].storeId, 66)
|
||||
XCTAssertEqual(api.refundCalls[0].orderNumber, "STORE001")
|
||||
XCTAssertEqual(api.refundCalls[0].mode, .partial)
|
||||
XCTAssertEqual(api.refundCalls[0].amount, "50.00")
|
||||
XCTAssertEqual(api.refundCalls[0].reason, "部分退款")
|
||||
}
|
||||
|
||||
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
|
||||
/// 店长订单列表测试替身,记录 storeOrderList 与退款调用。
|
||||
private final class StoreAdminMockOrderService: OrderServing {
|
||||
struct ListCall: Equatable {
|
||||
let storeId: Int?
|
||||
let page: Int
|
||||
let userPhone: String?
|
||||
let orderStatus: Int?
|
||||
let startTime: String?
|
||||
let endTime: String?
|
||||
}
|
||||
|
||||
struct RefundCall: Equatable {
|
||||
let storeId: Int
|
||||
let orderNumber: String
|
||||
let mode: OrderRefundMode
|
||||
let amount: String
|
||||
let reason: String
|
||||
}
|
||||
|
||||
var responses: [ListPayload<StoreOrderListItem>] = []
|
||||
private(set) var listCalls: [ListCall] = []
|
||||
private(set) var refundCalls: [RefundCall] = []
|
||||
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
func writeOffList(
|
||||
scenicId: Int,
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
orderVerifyStatus: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
userPhone: String?
|
||||
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
|
||||
func cancelOrder(orderNumber: String) async throws {}
|
||||
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
|
||||
|
||||
func storeOrderList(
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
userPhone: String?,
|
||||
orderStatus: Int?,
|
||||
orderType: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?
|
||||
) async throws -> ListPayload<StoreOrderListItem> {
|
||||
listCalls.append(
|
||||
ListCall(
|
||||
storeId: storeId,
|
||||
page: page,
|
||||
userPhone: userPhone,
|
||||
orderStatus: orderStatus,
|
||||
startTime: startTime,
|
||||
endTime: endTime
|
||||
)
|
||||
)
|
||||
return responses.isEmpty ? ListPayload(total: 0, list: []) : responses.removeFirst()
|
||||
}
|
||||
|
||||
func storeOrderRefund(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
refundType: OrderRefundMode,
|
||||
refundAmount: String,
|
||||
refundReason: String
|
||||
) async throws {
|
||||
refundCalls.append(
|
||||
RefundCall(
|
||||
storeId: storeId,
|
||||
orderNumber: orderNumber,
|
||||
mode: refundType,
|
||||
amount: refundAmount,
|
||||
reason: refundReason
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func writeOff(orderNumber: String) async throws {}
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
||||
}
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {}
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {}
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||
}
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {}
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||
}
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { [] }
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {}
|
||||
}
|
||||
222
suixinkanTests/WriteOffListViewModelTests.swift
Normal file
222
suixinkanTests/WriteOffListViewModelTests.swift
Normal file
@ -0,0 +1,222 @@
|
||||
//
|
||||
// WriteOffListViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 核销管理页 ViewModel 测试,覆盖筛选、分页与核销流程。
|
||||
final class WriteOffListViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空列表且不请求接口。
|
||||
func testReloadClearsDataWhenScenicIsMissing() async throws {
|
||||
let api = WriteOffMockOrderService()
|
||||
api.responses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = WriteOffListViewModel()
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, storeId: 66)
|
||||
XCTAssertFalse(viewModel.orders.isEmpty)
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.orders.isEmpty)
|
||||
XCTAssertEqual(viewModel.total, 0)
|
||||
XCTAssertEqual(api.listCalls.count, 1)
|
||||
}
|
||||
|
||||
/// 测试状态 Tab 与搜索参数会传入服务层。
|
||||
func testReloadUsesStatusAndPhoneFilters() async throws {
|
||||
let api = WriteOffMockOrderService()
|
||||
api.responses = [
|
||||
try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success"),
|
||||
try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")
|
||||
]
|
||||
let viewModel = WriteOffListViewModel()
|
||||
viewModel.searchPhone = "13800000000"
|
||||
viewModel.selectedStatus = 18
|
||||
viewModel.filterStartDate = Self.date("2026-05-01")
|
||||
viewModel.filterEndDate = Self.date("2026-05-31")
|
||||
|
||||
try await viewModel.reload(api: api, scenicId: 88, storeId: 66)
|
||||
|
||||
XCTAssertEqual(api.listCalls[0].orderStatus, 18)
|
||||
XCTAssertNil(api.listCalls[0].orderVerifyStatus)
|
||||
XCTAssertEqual(api.listCalls[0].userPhone, "13800000000")
|
||||
XCTAssertEqual(api.listCalls[0].startTime, "2026-05-01")
|
||||
XCTAssertEqual(api.listCalls[0].endTime, "2026-05-31")
|
||||
|
||||
viewModel.selectedStatus = 1
|
||||
try await viewModel.reload(api: api, scenicId: 88)
|
||||
|
||||
XCTAssertNil(api.listCalls[1].orderStatus)
|
||||
XCTAssertEqual(api.listCalls[1].orderVerifyStatus, 1)
|
||||
}
|
||||
|
||||
/// 测试核销成功后刷新列表。
|
||||
func testVerifyRefreshesListAfterSuccess() async throws {
|
||||
let api = WriteOffMockOrderService()
|
||||
api.responses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = WriteOffListViewModel()
|
||||
|
||||
try await viewModel.verify(api: api, scenicId: 88, storeId: 66, orderNumber: "VERIFY001")
|
||||
|
||||
XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"])
|
||||
XCTAssertEqual(api.listCalls.count, 1)
|
||||
XCTAssertEqual(viewModel.orders.count, 1)
|
||||
XCTAssertFalse(viewModel.isVerifying)
|
||||
}
|
||||
|
||||
/// 测试扫码命中当前列表时返回匹配订单。
|
||||
func testMatchedWriteOffOrderFindsCurrentListItem() async throws {
|
||||
let api = WriteOffMockOrderService()
|
||||
api.responses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
|
||||
let viewModel = WriteOffListViewModel()
|
||||
try await viewModel.reload(api: api, scenicId: 88)
|
||||
|
||||
let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "order_number=VERIFY001"))
|
||||
XCTAssertEqual(result.orderNumber, "VERIFY001")
|
||||
XCTAssertEqual(result.matched?.orderNumber, "VERIFY001")
|
||||
}
|
||||
|
||||
/// 测试 canVerify 会排除已核销状态。
|
||||
func testCanVerifyExcludesVerifiedStatus() throws {
|
||||
let viewModel = WriteOffListViewModel()
|
||||
let verified = try JSONDecoder().decode(
|
||||
WriteOffOrderItem.self,
|
||||
from: Data(
|
||||
"""
|
||||
{
|
||||
"order_number": "V2",
|
||||
"order_verification_status": "已核销",
|
||||
"pay_time": "",
|
||||
"project_name": "",
|
||||
"user_phone": "",
|
||||
"order_amount": "",
|
||||
"order_status_name": "",
|
||||
"order_verification_time": ""
|
||||
}
|
||||
""".utf8
|
||||
)
|
||||
)
|
||||
XCTAssertFalse(viewModel.canVerify(verified))
|
||||
}
|
||||
|
||||
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
|
||||
/// 核销列表测试替身,记录 writeOffList 与核销调用。
|
||||
private final class WriteOffMockOrderService: OrderServing {
|
||||
struct ListCall: Equatable {
|
||||
let scenicId: Int
|
||||
let storeId: Int?
|
||||
let page: Int
|
||||
let orderStatus: Int?
|
||||
let orderVerifyStatus: Int?
|
||||
let startTime: String?
|
||||
let endTime: String?
|
||||
let userPhone: String?
|
||||
}
|
||||
|
||||
var responses: [ListPayload<WriteOffOrderItem>] = []
|
||||
private(set) var listCalls: [ListCall] = []
|
||||
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> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
func writeOffList(
|
||||
scenicId: Int,
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
orderVerifyStatus: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
userPhone: String?
|
||||
) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
listCalls.append(
|
||||
ListCall(
|
||||
scenicId: scenicId,
|
||||
storeId: storeId,
|
||||
page: page,
|
||||
orderStatus: orderStatus,
|
||||
orderVerifyStatus: orderVerifyStatus,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
userPhone: userPhone
|
||||
)
|
||||
)
|
||||
return responses.isEmpty ? ListPayload(total: 0, list: []) : responses.removeFirst()
|
||||
}
|
||||
|
||||
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
|
||||
func cancelOrder(orderNumber: String) async throws {}
|
||||
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
|
||||
func storeOrderList(
|
||||
storeId: Int?,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
userPhone: String?,
|
||||
orderStatus: Int?,
|
||||
orderType: Int?,
|
||||
startTime: String?,
|
||||
endTime: String?
|
||||
) async throws -> ListPayload<StoreOrderListItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
func storeOrderRefund(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
refundType: OrderRefundMode,
|
||||
refundAmount: String,
|
||||
refundReason: String
|
||||
) async throws {}
|
||||
|
||||
func writeOff(orderNumber: String) async throws {
|
||||
verifiedOrderNumbers.append(orderNumber)
|
||||
}
|
||||
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
|
||||
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
|
||||
}
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {}
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {}
|
||||
func storeOrderShootingDetail(
|
||||
storeId: Int,
|
||||
orderNumber: String,
|
||||
scenicSpotId: Int,
|
||||
photogUid: Int
|
||||
) async throws -> StoreOrderShootingDetailResponse {
|
||||
try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
|
||||
}
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {}
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
|
||||
}
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { [] }
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {}
|
||||
}
|
||||
Reference in New Issue
Block a user