Add order tail flows and migrate queue, message, settlement, and audit modules.

Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent fb8430889b
commit 1a0d1c25f4
63 changed files with 9823 additions and 58 deletions

View File

@ -0,0 +1,27 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": "2",
"list": [
{
"id": "4101",
"order_number": "DEP001",
"user_phone": "13800000000",
"amount": 99.5,
"status": "20",
"status_name": "待核销",
"created_at": "2026-05-23 09:00:00"
},
{
"id": 4102,
"order_number": "DEP002",
"user_phone": 13900000000,
"amount": "199.00",
"status": 50,
"status_name": "已退款",
"created_at": "2026-05-22 18:30:00"
}
]
}
}

View File

@ -0,0 +1,30 @@
{
"code": 100000,
"msg": "success",
"data": {
"has_more": 0,
"last_id": 99,
"items": [
{
"id": 100,
"type": 2,
"type_name": "核销",
"title": "",
"content": "订单已核销",
"push_at": "2026-05-23 02:50",
"created_at": "2026-05-23 02:50",
"is_read": "1"
},
{
"id": 99,
"type": 0,
"type_name": "",
"title": "",
"content": "系统维护通知",
"push_at": "2026-05-23 02:40",
"created_at": "2026-05-23 02:40",
"is_read": false
}
]
}
}

View File

@ -0,0 +1,30 @@
{
"code": 100000,
"msg": "success",
"data": {
"has_more": 1,
"last_id": "101",
"items": [
{
"id": "101",
"type": "1",
"type_name": "订单",
"title": "新订单提醒",
"content": "你有新的拍摄订单",
"push_at": "2026-05-23 02:55",
"created_at": "2026-05-23 02:55",
"is_read": 0
},
{
"id": 100,
"type": 2,
"type_name": "核销",
"title": "",
"content": "订单已核销",
"push_at": "2026-05-23 02:50",
"created_at": "2026-05-23 02:50",
"is_read": "1"
}
]
}
}

View File

@ -0,0 +1,36 @@
{
"code": 100000,
"msg": "success",
"data": {
"project_name": "多点旅拍",
"project_type": "19",
"project_type_name": "多点旅拍",
"photog_spot_list": [
{
"scenic_spot_id": "901",
"photog_uid": "42",
"scenic_spot_name": "湖心亭",
"photog_nickname": "小景",
"photog_name": "测试摄影师",
"files": [
{
"file_name": "spot-901.jpg",
"file_url": "https://cdn.example.com/multi/spot-901.jpg",
"file_type": "1",
"file_size": "204800",
"cover_url": "",
"upload_time": "2026-05-23 04:22:00"
},
{
"file_name": "spot-901.mp4",
"file_url": "https://cdn.example.com/multi/spot-901.mp4",
"file_type": 2,
"file_size": 1048576,
"cover_url": "https://cdn.example.com/multi/spot-901-cover.jpg",
"upload_time": "2026-05-23 04:23:00"
}
]
}
]
}
}

View File

@ -0,0 +1,14 @@
{
"code": 100000,
"msg": "success",
"data": [
{
"id": 901,
"name": "湖心亭"
},
{
"id": "902",
"name": "观景台"
}
]
}

View File

@ -0,0 +1,63 @@
{
"code": 100000,
"msg": "success",
"data": {
"stats": {
"type": "1"
},
"list": {
"total": "2",
"page": "1",
"page_size": "20",
"list": [
{
"id": "1001",
"queue_code": "A001",
"mobile": "13800000000",
"status": "1",
"status_text": "等待中",
"wait_min": "8",
"ahead_count": "0",
"is_called": "1",
"queue_ban_label": "限制排队7天",
"identity_tag": "打野摄影师",
"queue_time": "2026-05-23 03:05:00",
"queue_count_today": "2",
"uid": "7001",
"mark_as_photog": "0",
"mark_as_freelance_photog": "1",
"is_miss_requeue": "1",
"is_miss_requeue_text": "重排",
"created_at": "2026-05-23 03:05",
"called_at": "2026-05-23 03:06",
"expired_at": "",
"finished_at": ""
},
{
"id": 1002,
"queue_code": "A002",
"mobile": "13900000000",
"status": 1,
"status_text": "等待中",
"wait_min": 10,
"ahead_count": 1,
"is_called": 0,
"queue_ban_label": "",
"identity_tag": "普通用户",
"queue_time": "05-23 03:07",
"queue_count_today": 1,
"uid": 7002,
"mark_as_photog": 1,
"mark_as_freelance_photog": 0,
"is_miss_requeue": 0,
"is_miss_requeue_text": "",
"created_at": "2026-05-23 03:07",
"called_at": "",
"expired_at": "",
"finished_at": ""
}
]
},
"time": "2026-05-23 03:08"
}
}

View File

@ -0,0 +1,7 @@
{
"code": 100000,
"msg": "success",
"data": {
"qrcode_url": "https://cdn.example.com/queue/spot-9.png"
}
}

View File

@ -0,0 +1,40 @@
{
"code": 100000,
"msg": "success",
"data": {
"exists": "1",
"setting": {
"id": "11",
"scenic_id": "88",
"scenic_spot_id": "9",
"scenic_spot_name": "东门打卡点",
"photo_estimate_min": "2",
"photo_estimate_sec": "30",
"first_notice_threshold_pos": "3",
"first_notice_sms_enabled": "1",
"first_notice_call_enabled": "1",
"second_notice_threshold_pos": "1",
"second_notice_sms_enabled": "0",
"second_notice_call_enabled": "1",
"countdown_broadcast_interval_sec": "15",
"countdown_readable_threshold_sec": "60",
"business_start_time": "08:00:00",
"business_end_time": "22:00:59",
"status": "1",
"remark": "自动叫号",
"voice_broadcasts": [
{
"content": "A001请到拍摄点",
"sort_order": "1"
},
{
"content": "请后续游客到等候区",
"sort_order": "2"
}
],
"created_at": "2026-05-23 03:10",
"updated_at": "2026-05-23 03:11"
},
"time": "2026-05-23 03:12"
}
}

View File

@ -0,0 +1,9 @@
{
"code": 100000,
"msg": "success",
"data": {
"queue_count": "5",
"avg_wait_min": "12.5",
"time": "2026-05-23 03:10"
}
}

View File

@ -0,0 +1,21 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": "2",
"list": [
{
"id": "901",
"name": "湖心亭",
"status": "1",
"status_label": "启用"
},
{
"id": 902,
"name": "观景台",
"status": 0,
"status_label": "停用"
}
]
}
}

View File

@ -0,0 +1,44 @@
{
"code": 100000,
"msg": "success",
"data": {
"scenic_spot_id": "901",
"scenic_spot_name": "湖心亭",
"order_type": "4",
"order_type_name": "押金订单",
"order_comment": {
"star_shooting": "5",
"star_retouching": "4",
"star_scenery": "5",
"star_service": "5",
"star_camera": "4",
"content": "拍摄体验很好",
"created_at": "2026-05-23 12:00:00"
},
"material_list": [
{
"file_name": "negative-video.mp4",
"file_url": "https://cdn.example.com/deposit/negative-video.mp4",
"file_type": "1",
"file_size": "10485760",
"cover_url": "https://cdn.example.com/deposit/negative-video.jpg"
},
{
"file_name": "negative-photo.jpg",
"file_url": "https://cdn.example.com/deposit/negative-photo.jpg",
"file_type": 2,
"file_size": 204800,
"cover_url": ""
}
],
"complete_list": [
{
"file_name": "retouch.jpg",
"file_url": "https://cdn.example.com/deposit/retouch.jpg",
"file_type": "2",
"file_size": "409600",
"cover_url": "https://cdn.example.com/deposit/retouch-thumb.jpg"
}
]
}
}

View File

@ -53,21 +53,22 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_invite", title: ""), .destination(.photographerInvite))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_detail", title: ""), .destination(.depositOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "withdrawal_audit", title: ""), .destination(.withdrawalAudit))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""), .destination(.scenicSettlement))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""), .destination(.scenicSettlementReview))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
}
///
func testKnownUnmigratedRoutesResolveToPlaceholders() {
XCTAssertEqual(
HomeMenuRouter.resolve(uri: "scenic_settlement", title: ""),
.destination(.modulePlaceholder(uri: "scenic_settlement", title: "景区结算"))
)
XCTAssertEqual(
HomeMenuRouter.resolve(uri: "scenic_settlement_review", title: ""),
.destination(.modulePlaceholder(uri: "scenic_settlement_review", title: "结算审核"))
)
XCTAssertEqual(
HomeMenuRouter.resolve(uri: "message_center", title: ""),
.destination(.modulePlaceholder(uri: "message_center", title: "消息中心"))
HomeMenuRouter.resolve(uri: "live_stream_management", title: ""),
.destination(.modulePlaceholder(uri: "live_stream_management", title: "直播管理"))
)
}
@ -114,6 +115,8 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "payment_code"), "payment_collection")
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "deposit_order_shooting_info"), "deposit_order")
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-order-manage"), "photographer_orders")
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "/scenic-queue"), "queue_management")
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "queue_management"), "queue_management")
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "android_only_module"), "android_only_module")
}
@ -187,6 +190,11 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertTrue(uris.contains("schedule_management"))
XCTAssertTrue(uris.contains("registration_invitation"))
XCTAssertTrue(uris.contains("invite_record"))
XCTAssertTrue(uris.contains("deposit_order"))
XCTAssertTrue(uris.contains("deposit_order_shooting_info"))
XCTAssertTrue(uris.contains("message_center"))
XCTAssertTrue(uris.contains("/scenic-queue"))
XCTAssertTrue(uris.contains("queue_management"))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "album_list", title: ""), .destination(.albumList))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_management", title: ""), .destination(.sampleLibrary))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "sample_upload", title: ""), .destination(.sampleUpload))
@ -197,6 +205,11 @@ final class HomeMenuRouterTests: XCTestCase {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "registration_invitation", title: ""), .destination(.photographerInvite))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "invite_record", title: ""), .destination(.inviteRecord))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order", title: ""), .destination(.depositOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "deposit_order_shooting_info", title: ""), .destination(.depositOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "message_center", title: ""), .destination(.messageCenter))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-queue", title: ""), .destination(.queueManagement))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "queue_management", title: ""), .destination(.queueManagement))
}
#endif

View File

@ -103,4 +103,41 @@ private final class MockMainTabOrderService: OrderServing {
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 {}
}

View File

@ -0,0 +1,167 @@
//
// MessageCenterViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class MessageCenterViewModelTests: XCTestCase {
func testReloadFirstPageMapsMessagesAndPaginationState() async {
let api = MessageCenterMock()
api.listResponses = [
MessageListResponse(hasMore: true, lastId: 101, items: [
MessageEntity(id: 101, type: 1, typeName: "", title: "新订单提醒", content: "有新订单", pushAt: "2026-06-25 10:00", isRead: false),
MessageEntity(id: 100, type: 2, typeName: "核销", title: "", content: "核销完成", pushAt: "2026-06-25 09:00", isRead: true)
])
]
let viewModel = MessageCenterViewModel()
viewModel.selectedFilter = .unread
await viewModel.reloadFirstPage(api: api)
XCTAssertEqual(api.listRequests.map(\.unread), [1])
XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100"])
XCTAssertEqual(viewModel.messages[0].title, "新订单提醒")
XCTAssertEqual(viewModel.messages[0].type, .order)
XCTAssertEqual(viewModel.messages[1].title, "核销")
XCTAssertEqual(viewModel.messages[1].type, .writeOff)
XCTAssertEqual(viewModel.unreadCount, 1)
XCTAssertTrue(viewModel.hasMoreMessages)
XCTAssertEqual(viewModel.lastId, 101)
}
func testLoadMoreDeduplicatesSortsAndStopsAtTail() async {
let api = MessageCenterMock()
api.listResponses = [
MessageListResponse(hasMore: true, lastId: 101, items: [
MessageEntity(id: 101, type: 1, title: "A", content: "A", pushAt: "2026-06-25 10:00"),
MessageEntity(id: 100, type: 3, title: "B", content: "B", pushAt: "2026-06-25 09:00")
]),
MessageListResponse(hasMore: false, lastId: 99, items: [
MessageEntity(id: 100, type: 3, title: "B2", content: "B2", pushAt: "2026-06-25 09:00"),
MessageEntity(id: 99, type: 0, title: "C", content: "C", pushAt: "2026-06-24 08:00")
])
]
let viewModel = MessageCenterViewModel()
await viewModel.reloadFirstPage(api: api)
await viewModel.loadMore(api: api)
await viewModel.loadMore(api: api)
XCTAssertEqual(api.listRequests.map(\.lastId), [0, 101])
XCTAssertEqual(viewModel.messages.map(\.id), ["msg_101", "msg_100", "msg_99"])
XCTAssertEqual(viewModel.messages[1].title, "B2")
XCTAssertFalse(viewModel.hasMoreMessages)
XCTAssertEqual(viewModel.lastId, 99)
}
func testReloadFailureClearsStaleMessagesAndPaging() async {
let api = MessageCenterMock()
api.listResponses = [
MessageListResponse(hasMore: true, lastId: 101, items: [
MessageEntity(id: 101, type: 1, title: "A")
])
]
let viewModel = MessageCenterViewModel()
await viewModel.reloadFirstPage(api: api)
api.listError = TestError.sample
await viewModel.reloadFirstPage(api: api)
XCTAssertTrue(viewModel.messages.isEmpty)
XCTAssertFalse(viewModel.hasMoreMessages)
XCTAssertEqual(viewModel.lastId, 0)
XCTAssertTrue(viewModel.loadFailed)
}
func testMarkReadAllReadAndDeleteUpdateOnlyAfterSuccess() async throws {
let api = MessageCenterMock()
api.listResponses = [
MessageListResponse(items: [
MessageEntity(id: 101, type: 1, title: "A", isRead: false),
MessageEntity(id: 100, type: 0, title: "B", isRead: false)
])
]
let viewModel = MessageCenterViewModel()
await viewModel.reloadFirstPage(api: api)
try await viewModel.markAsRead(api: api, item: try XCTUnwrap(viewModel.messages.first))
XCTAssertEqual(api.readIds, [101])
XCTAssertEqual(viewModel.messages.map(\.isRead), [true, false])
api.readIds = []
try await viewModel.markAllAsRead(api: api)
XCTAssertEqual(api.readIds, [100])
XCTAssertTrue(viewModel.messages.allSatisfy(\.isRead))
let item = try XCTUnwrap(viewModel.messages.first)
try await viewModel.deleteMessage(api: api, item: item)
XCTAssertEqual(api.deleteIds, [101])
XCTAssertFalse(viewModel.messages.contains(where: { $0.id == item.id }))
}
func testFailedReadAndDeleteKeepLocalState() async throws {
let api = MessageCenterMock()
api.listResponses = [MessageListResponse(items: [MessageEntity(id: 101, type: 1, title: "A", isRead: false)])]
let viewModel = MessageCenterViewModel()
await viewModel.reloadFirstPage(api: api)
let item = try XCTUnwrap(viewModel.messages.first)
api.readError = TestError.sample
await XCTAssertThrowsErrorAsync(try await viewModel.markAsRead(api: api, item: item))
XCTAssertFalse(try XCTUnwrap(viewModel.messages.first).isRead)
api.readError = nil
api.deleteError = TestError.sample
await XCTAssertThrowsErrorAsync(try await viewModel.deleteMessage(api: api, item: item))
XCTAssertEqual(viewModel.messages.map(\.id), [item.id])
}
}
@MainActor
private final class MessageCenterMock: MessageCenterServing {
var listResponses: [MessageListResponse] = []
var listError: Error?
var readError: Error?
var deleteError: Error?
var readIds: [Int] = []
var deleteIds: [Int] = []
private(set) var listRequests: [(lastId: Int, limit: Int, unread: Int)] = []
func messageList(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
listRequests.append((lastId, limit, unread))
if let listError { throw listError }
return listResponses.isEmpty ? MessageListResponse() : listResponses.removeFirst()
}
func messageRead(id: Int) async throws {
if let readError { throw readError }
readIds.append(id)
}
func messageDelete(id: Int) async throws {
if let deleteError { throw deleteError }
deleteIds.append(id)
}
}
private enum TestError: LocalizedError {
case sample
var errorDescription: String? { "测试错误" }
}
private func XCTAssertThrowsErrorAsync(
_ expression: @autoclosure () async throws -> Void,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
try await expression()
XCTFail("Expected error", file: file, line: line)
} catch {}
}

View File

@ -18,6 +18,8 @@ final class NavigationRouterTests: XCTestCase {
XCTAssertTrue(AppRoute.home(.wallet).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.storeDetail(try! Self.firstOrder())).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.writeOffDetail(try! Self.firstWriteOffOrder())).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.multiTravelTaskUpload(orderNumber: "MT001")).hidesTabBarWhenPushed)
XCTAssertTrue(AppRoute.orders(.orderTrailer(orderNumber: "MT001", title: "尾片上传")).hidesTabBarWhenPushed)
}
/// Tab
@ -70,10 +72,14 @@ final class NavigationRouterTests: XCTestCase {
router.navigate(to: .orders(.storeDetail(order)))
router.navigate(to: .orders(.writeOffDetail(writeOff)))
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
router.navigate(to: .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
XCTAssertEqual(router.path.count, 2)
XCTAssertEqual(router.path.count, 4)
XCTAssertEqual(router.path[0], .orders(.storeDetail(order)))
XCTAssertEqual(router.path[1], .orders(.writeOffDetail(writeOff)))
XCTAssertEqual(router.path[2], .orders(.multiTravelTaskUpload(orderNumber: "MT001")))
XCTAssertEqual(router.path[3], .orders(.orderTrailer(orderNumber: "MT001", title: "视频预告")))
}
///

View File

@ -117,4 +117,53 @@ private final class DetailMockOrderService: OrderServing {
}
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")
}
}

View File

@ -0,0 +1,596 @@
//
// OrderLongTailTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
/// 退 API ViewModel
final class OrderLongTailTests: XCTestCase {
/// 使
func testDepositOrderAPIRequestsAndDecodesLossyFields() async throws {
let session = LongTailRecordingURLSession(responses: [
try TestFixture.data(named: "deposit_order_list_success"),
try TestFixture.data(named: "empty_success"),
try TestFixture.data(named: "empty_success")
])
let api = OrdersAPI(client: APIClient(session: session))
let list = try await api.depositOrderList(scenicId: 88, page: 0, pageSize: 0)
try await api.depositOrderWriteOff(orderNumber: "DEP001")
try await api.depositOrderRefund(orderNumber: "DEP002", refundReason: "游客取消行程")
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/yf-handset-app/photog/order/deposit-list",
"/api/yf-handset-app/photog/order/deposit-writeoff",
"/api/yf-handset-app/photog/order/deposit-refund"
])
let query = longTailQueryItems(from: try XCTUnwrap(session.requests.first))
XCTAssertEqual(query["scenic_id"], "88")
XCTAssertEqual(query["page"], "1")
XCTAssertEqual(query["page_size"], "1")
XCTAssertEqual(list.total, 2)
XCTAssertEqual(list.list[1].id, 4102)
XCTAssertEqual(list.list[1].userPhone, "13900000000")
let writeOffBody = try longTailBodyObject(from: session.requests[1])
XCTAssertEqual(writeOffBody["order_number"] as? String, "DEP001")
let refundBody = try longTailBodyObject(from: session.requests[2])
XCTAssertEqual(refundBody["order_number"] as? String, "DEP002")
XCTAssertEqual(refundBody["refund_reason"] as? String, "游客取消行程")
}
/// 退 pathquerybody
func testShootingRefundAndHistoryAPIRequestsAndDecodes() async throws {
let session = LongTailRecordingURLSession(responses: [
try TestFixture.data(named: "store_order_shooting_detail_success"),
try TestFixture.data(named: "multi_travel_shoot_history_success"),
try TestFixture.data(named: "empty_success")
])
let api = OrdersAPI(client: APIClient(session: session))
let shooting = try await api.storeOrderShootingDetail(
storeId: 66,
orderNumber: "DEP001",
scenicSpotId: 901,
photogUid: 42
)
let history = try await api.multiTravelShootHistory(orderNumber: "MT001")
try await api.orderRefund(
orderNumber: "STORE001",
refundType: .partial,
refundAmount: "10.50",
refundReason: "游客取消部分项目"
)
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/app/store/order/shooting-detail",
"/api/yf-handset-app/photog/order/multi-travel/shoot-history",
"/api/yf-handset-app/photog/order/refund"
])
let shootingQuery = longTailQueryItems(from: session.requests[0])
XCTAssertEqual(shootingQuery["store_id"], "66")
XCTAssertEqual(shootingQuery["order_number"], "DEP001")
XCTAssertEqual(shootingQuery["scenic_spot_id"], "901")
XCTAssertEqual(shootingQuery["photog_uid"], "42")
XCTAssertEqual(shooting.orderComment?.starShooting, 5)
XCTAssertEqual(shooting.materialList.first?.fileSize, 10_485_760)
XCTAssertTrue(shooting.materialList.first?.isVideo == true)
XCTAssertEqual(longTailQueryItems(from: session.requests[1])["order_number"], "MT001")
XCTAssertEqual(history.projectType, 19)
XCTAssertEqual(history.photogSpotList.first?.photographerDisplayName, "小景")
XCTAssertEqual(history.photogSpotList.first?.files.count, 2)
let refundBody = try longTailBodyObject(from: session.requests[2])
XCTAssertEqual(refundBody["order_number"] as? String, "STORE001")
XCTAssertEqual(refundBody["refund_type"] as? Int, OrderRefundMode.partial.rawValue)
XCTAssertEqual(refundBody["refund_amount"] as? String, "10.50")
XCTAssertNil(refundBody["store_id"])
}
/// pathquerybody
func testMultiTravelTaskUploadAPIRequestsAndDecodes() async throws {
let session = LongTailRecordingURLSession(responses: [
try TestFixture.data(named: "multi_travel_verified_spots_success"),
try TestFixture.data(named: "empty_success")
])
let api = OrdersAPI(client: APIClient(session: session))
let spots = try await api.multiTravelVerifiedScenicSpotList(orderNumber: "MT001")
try await api.multiTravelUploadMaterial(
MultiTravelUploadMaterialRequest(
orderNumber: "MT001",
scenicSpotId: 901,
cloudFile: [MultiTravelCloudFileItem(fileId: 7001)],
uploadFile: [MultiTravelUploadFileItem(fileName: "spot.jpg", fileUrl: "https://cdn.example.com/spot.jpg")]
)
)
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list",
"/api/yf-handset-app/photog/order/multi-travel/upload-material"
])
XCTAssertEqual(longTailQueryItems(from: session.requests[0])["order_number"], "MT001")
XCTAssertEqual(spots.map(\.id), [901, 902])
XCTAssertEqual(spots.last?.name, "观景台")
let body = try longTailBodyObject(from: session.requests[1])
XCTAssertEqual(body["order_number"] as? String, "MT001")
XCTAssertEqual(body["scenic_spot_id"] as? Int, 901)
XCTAssertEqual((body["cloud_file"] as? [[String: Any]])?.first?["file_id"] as? Int, 7001)
XCTAssertEqual((body["upload_file"] as? [[String: Any]])?.first?["file_url"] as? String, "https://cdn.example.com/spot.jpg")
}
///
func testDepositListClearsWithoutScenicAndStopsAtLastPage() async throws {
let service = LongTailMockOrderService()
service.depositListResponses = [try Self.depositPayload()]
let viewModel = DepositOrderListViewModel()
await viewModel.reload(api: service, scenicId: 88, reset: true)
await viewModel.reload(api: service, scenicId: 88, reset: false)
await viewModel.reload(api: service, scenicId: nil, reset: true)
XCTAssertTrue(viewModel.orders.isEmpty)
XCTAssertEqual(viewModel.total, 0)
XCTAssertEqual(viewModel.page, 1)
XCTAssertEqual(service.depositListCalls, [
.init(scenicId: 88, page: 1, pageSize: 10)
])
}
/// 退
func testDepositWriteOffAndRefundRefreshOrKeepListOnFailure() async throws {
let service = LongTailMockOrderService()
service.depositListResponses = [
try Self.depositPayload(),
try Self.depositPayload(),
try Self.depositPayload()
]
let viewModel = DepositOrderListViewModel()
await viewModel.reload(api: service, scenicId: 88, reset: true)
let writeOffSuccess = await viewModel.writeOff(api: service, scenicId: 88, orderNumber: "DEP001")
let emptyReason = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: " ")
service.depositRefundError = APIError.serverCode(500, "退款失败")
let refundFailure = await viewModel.refund(api: service, scenicId: 88, orderNumber: "DEP001", reason: "游客取消")
XCTAssertTrue(writeOffSuccess)
XCTAssertFalse(emptyReason)
XCTAssertFalse(refundFailure)
XCTAssertEqual(service.depositWriteOffNumbers, ["DEP001"])
XCTAssertEqual(service.depositRefundCalls, [.init(orderNumber: "DEP001", reason: "游客取消")])
XCTAssertEqual(service.depositListCalls.count, 2)
XCTAssertEqual(viewModel.orders.count, 2)
XCTAssertNil(viewModel.operatingOrderNumber)
XCTAssertEqual(viewModel.errorMessage, "退款失败")
}
///
func testDepositDetailAndShootingSkipWithoutStore() async {
let service = LongTailMockOrderService()
let detailViewModel = DepositOrderDetailViewModel()
let shootingViewModel = DepositOrderShootingInfoViewModel()
await detailViewModel.load(api: service, storeId: nil, orderNumber: "DEP001")
await shootingViewModel.load(api: service, storeId: nil, orderNumber: "DEP001", scenicSpotId: 901, photogUid: 42)
XCTAssertTrue(service.storeDetailCalls.isEmpty)
XCTAssertTrue(service.storeShootingCalls.isEmpty)
XCTAssertEqual(detailViewModel.errorMessage, "当前账号缺少门店信息")
XCTAssertEqual(shootingViewModel.errorMessage, "当前账号缺少门店信息")
}
/// 退
func testOrderRefundValidationAndSubmit() async throws {
let service = LongTailMockOrderService()
let viewModel = OrderRefundViewModel()
let refundable = try Self.orderEntity(orderStatus: 18, orderType: 4, actualPayAmount: "99.50")
let unavailable = try Self.orderEntity(orderStatus: 50, orderType: 4, actualPayAmount: "99.50")
XCTAssertTrue(viewModel.canRefund(refundable))
XCTAssertFalse(viewModel.canRefund(unavailable))
viewModel.begin(item: refundable)
XCTAssertEqual(viewModel.amount, "99.50")
let missingReasonSuccess = await viewModel.submit(api: service, item: refundable)
XCTAssertFalse(missingReasonSuccess)
XCTAssertEqual(viewModel.errorMessage, "请输入退款原因")
viewModel.reason = "部分退款"
viewModel.mode = .partial
viewModel.amount = "12.345"
let invalidAmountSuccess = await viewModel.submit(api: service, item: refundable)
XCTAssertFalse(invalidAmountSuccess)
XCTAssertEqual(viewModel.errorMessage, "请输入有效的退款金额")
viewModel.amount = "120.00"
let overLimitSuccess = await viewModel.submit(api: service, item: refundable)
XCTAssertFalse(overLimitSuccess)
XCTAssertEqual(viewModel.errorMessage, "退款金额不能大于可退金额")
viewModel.amount = "12.3"
let submitSuccess = await viewModel.submit(api: service, item: refundable)
XCTAssertTrue(submitSuccess)
XCTAssertEqual(service.orderRefundCalls, [
.init(orderNumber: "STORE001", mode: .partial, amount: "12.30", reason: "部分退款")
])
XCTAssertNil(viewModel.errorMessage)
XCTAssertFalse(viewModel.submitting)
}
///
func testHistoricalShootingLoadSuccessAndFailure() async throws {
let service = LongTailMockOrderService()
service.historyResponses = [try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")]
let viewModel = HistoricalShootingInfoViewModel()
await viewModel.load(api: service, orderNumber: " ")
XCTAssertTrue(service.historyCalls.isEmpty)
XCTAssertEqual(viewModel.errorMessage, "订单号不能为空")
await viewModel.load(api: service, orderNumber: "MT001")
XCTAssertEqual(viewModel.projectName, "多点旅拍")
XCTAssertEqual(viewModel.spots.first?.scenicSpotName, "湖心亭")
XCTAssertEqual(viewModel.spots.first?.files.count, 2)
service.historyError = APIError.serverCode(500, "历史拍摄失败")
await viewModel.load(api: service, orderNumber: "MT001")
XCTAssertTrue(viewModel.spots.isEmpty)
XCTAssertEqual(viewModel.errorMessage, "历史拍摄失败")
}
///
func testMultiTravelTaskUploadLoadsAndRepairsSpotSelection() async throws {
let service = LongTailMockOrderService()
service.verifiedSpotResponses = [
try Self.verifiedSpots(),
[try Self.verifiedSpot(id: 902, name: "观景台")]
]
let viewModel = MultiTravelTaskUploadViewModel()
await viewModel.loadSpots(api: service)
XCTAssertTrue(service.verifiedSpotCalls.isEmpty)
viewModel.orderNumber = "MT001"
await viewModel.loadSpots(api: service)
XCTAssertEqual(viewModel.selectedSpotId, 901)
viewModel.selectSpot(id: 901)
await viewModel.loadSpots(api: service)
XCTAssertEqual(viewModel.selectedSpotId, 902)
service.verifiedSpotError = APIError.serverCode(500, "打卡点失败")
await viewModel.loadSpots(api: service)
XCTAssertTrue(viewModel.spots.isEmpty)
XCTAssertNil(viewModel.selectedSpotId)
XCTAssertEqual(viewModel.errorMessage, "打卡点失败")
}
///
func testMultiTravelTaskUploadValidatesAndSubmitsCloudFiles() async throws {
let service = LongTailMockOrderService()
service.verifiedSpotResponses = [try Self.verifiedSpots()]
let uploader = LongTailMockUploader()
let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
let missingScenicSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: nil)
XCTAssertFalse(missingScenicSuccess)
XCTAssertEqual(viewModel.errorMessage, "请先选择景区")
await viewModel.loadSpots(api: service)
let missingFileSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
XCTAssertFalse(missingFileSuccess)
XCTAssertEqual(viewModel.errorMessage, "请至少选择一个素材文件")
viewModel.mergeCloudFiles([TaskCloudSelectionItem(id: 7001, fileName: "cloud.jpg", fileType: 2, remark: "")])
let submitSuccess = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
XCTAssertTrue(submitSuccess)
XCTAssertTrue(viewModel.didSubmitSuccessfully)
XCTAssertEqual(service.uploadMaterialRequests, [
MultiTravelUploadMaterialRequest(
orderNumber: "MT001",
scenicSpotId: 901,
cloudFile: [MultiTravelCloudFileItem(fileId: 7001)],
uploadFile: []
)
])
XCTAssertTrue(uploader.uploadTaskFiles.isEmpty)
}
/// OSS
func testMultiTravelTaskUploadUploadsLocalFilesBeforeSubmit() async throws {
let service = LongTailMockOrderService()
service.verifiedSpotResponses = [try Self.verifiedSpots()]
let uploader = LongTailMockUploader()
uploader.uploadTaskFileURLs = ["https://cdn.example.com/local.jpg"]
let viewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
await viewModel.loadSpots(api: service)
viewModel.addLocalFile(data: Data([1, 2, 3]), fileName: "local.jpg")
let success = await viewModel.submit(api: service, uploadService: uploader, scenicId: 88)
XCTAssertTrue(success)
XCTAssertEqual(uploader.uploadTaskFiles.map(\.fileName), ["local.jpg"])
XCTAssertEqual(service.uploadMaterialRequests.first?.uploadFile, [
MultiTravelUploadFileItem(fileName: "local.jpg", fileUrl: "https://cdn.example.com/local.jpg")
])
let failingService = LongTailMockOrderService()
failingService.verifiedSpotResponses = [try Self.verifiedSpots()]
let failingUploader = LongTailMockUploader()
failingUploader.uploadError = APIError.serverCode(500, "上传失败")
let failingViewModel = MultiTravelTaskUploadViewModel(initialOrderNumber: "MT001")
await failingViewModel.loadSpots(api: failingService)
failingViewModel.addLocalFile(data: Data([1]), fileName: "bad.mp4")
let failure = await failingViewModel.submit(api: failingService, uploadService: failingUploader, scenicId: 88)
XCTAssertFalse(failure)
XCTAssertTrue(failingService.uploadMaterialRequests.isEmpty)
XCTAssertEqual(failingViewModel.errorMessage, "上传失败")
}
/// fixture
private static func depositPayload() throws -> ListPayload<DepositOrderListItem> {
try TestFixture.payload(ListPayload<DepositOrderListItem>.self, named: "deposit_order_list_success")
}
/// 退
private static func orderEntity(orderStatus: Int, orderType: Int, actualPayAmount: String) throws -> OrderEntity {
let json = """
{
"order_number": "STORE001",
"order_status": "\(orderStatus)",
"order_type": "\(orderType)",
"actual_pay_amount": "\(actualPayAmount)",
"actual_refund_amount": "0",
"refund_amount": "0",
"order_type_label": "",
"order_status_name": "退"
}
"""
return try JSONDecoder().decode(OrderEntity.self, from: Data(json.utf8))
}
/// fixture
private static func verifiedSpots() throws -> [MultiTravelVerifiedScenicSpotItem] {
try TestFixture.payload([MultiTravelVerifiedScenicSpotItem].self, named: "multi_travel_verified_spots_success")
}
///
private static func verifiedSpot(id: Int, name: String) throws -> MultiTravelVerifiedScenicSpotItem {
let json = #"{"id":"\#(id)","name":"\#(name)"}"#
return try JSONDecoder().decode(MultiTravelVerifiedScenicSpotItem.self, from: Data(json.utf8))
}
}
/// URLSession
private final class LongTailRecordingURLSession: URLSessionProtocol {
private var responses: [Data]
private(set) var requests: [URLRequest] = []
init(responses: [Data]) {
self.responses = responses
}
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
let data = responses.isEmpty ? Data(#"{"code":100000,"msg":"success"}"#.utf8) : responses.removeFirst()
return (
data,
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
)
}
}
@MainActor
/// 退
private final class LongTailMockOrderService: OrderServing {
struct DepositListCall: Equatable {
let scenicId: Int
let page: Int
let pageSize: Int
}
struct DepositRefundCall: Equatable {
let orderNumber: String
let reason: String
}
struct OrderRefundCall: Equatable {
let orderNumber: String
let mode: OrderRefundMode
let amount: String
let reason: String
}
var depositListResponses: [ListPayload<DepositOrderListItem>] = []
var detailResponses: [StoreOrderDetailResponse] = []
var shootingResponses: [StoreOrderShootingDetailResponse] = []
var historyResponses: [MultiTravelShootHistoryResponse] = []
var verifiedSpotResponses: [[MultiTravelVerifiedScenicSpotItem]] = []
var depositWriteOffError: Error?
var depositRefundError: Error?
var orderRefundError: Error?
var historyError: Error?
var verifiedSpotError: Error?
var uploadMaterialError: Error?
private(set) var depositListCalls: [DepositListCall] = []
private(set) var depositWriteOffNumbers: [String] = []
private(set) var depositRefundCalls: [DepositRefundCall] = []
private(set) var orderRefundCalls: [OrderRefundCall] = []
private(set) var storeDetailCalls: [(storeId: Int, orderNumber: String)] = []
private(set) var storeShootingCalls: [(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int)] = []
private(set) var historyCalls: [String] = []
private(set) var verifiedSpotCalls: [String] = []
private(set) var uploadMaterialRequests: [MultiTravelUploadMaterialRequest] = []
/// 使
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) async throws -> ListPayload<WriteOffOrderItem> {
ListPayload(total: 0, list: [])
}
/// 使
func writeOff(orderNumber: String) async throws {}
///
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
storeDetailCalls.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> {
depositListCalls.append(.init(scenicId: scenicId, page: page, pageSize: pageSize))
return depositListResponses.isEmpty ? ListPayload(total: 0, list: []) : depositListResponses.removeFirst()
}
///
func depositOrderWriteOff(orderNumber: String) async throws {
depositWriteOffNumbers.append(orderNumber)
if let depositWriteOffError {
throw depositWriteOffError
}
}
/// 退
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
depositRefundCalls.append(.init(orderNumber: orderNumber, reason: refundReason))
if let depositRefundError {
throw depositRefundError
}
}
///
func storeOrderShootingDetail(
storeId: Int,
orderNumber: String,
scenicSpotId: Int,
photogUid: Int
) async throws -> StoreOrderShootingDetailResponse {
storeShootingCalls.append((storeId: storeId, orderNumber: orderNumber, scenicSpotId: scenicSpotId, photogUid: photogUid))
return shootingResponses.isEmpty ? try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success") : shootingResponses.removeFirst()
}
/// 退
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
orderRefundCalls.append(.init(orderNumber: orderNumber, mode: refundType, amount: refundAmount, reason: refundReason))
if let orderRefundError {
throw orderRefundError
}
}
///
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
historyCalls.append(orderNumber)
if let historyError {
throw historyError
}
return historyResponses.isEmpty ? try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success") : historyResponses.removeFirst()
}
///
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
verifiedSpotCalls.append(orderNumber)
if let verifiedSpotError {
throw verifiedSpotError
}
return verifiedSpotResponses.isEmpty ? [] : verifiedSpotResponses.removeFirst()
}
///
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
uploadMaterialRequests.append(request)
if let uploadMaterialError {
throw uploadMaterialError
}
}
}
@MainActor
/// OSS
private final class LongTailMockUploader: OSSUploadServing {
struct UploadTaskFileCall: Equatable {
let fileName: String
let fileType: Int
let scenicId: Int
let dataSize: Int
}
var uploadTaskFileURLs: [String] = []
var uploadError: Error?
private(set) var uploadTaskFiles: [UploadTaskFileCall] = []
/// URL
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
uploadTaskFiles.append(.init(fileName: fileName, fileType: fileType, scenicId: scenicId, dataSize: data.count))
if let uploadError {
throw uploadError
}
onProgress(100)
return uploadTaskFileURLs.isEmpty ? "https://cdn.example.com/\(fileName)" : uploadTaskFileURLs.removeFirst()
}
/// 使
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
/// 使
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
/// 使
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
/// 使
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
/// 使
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
/// 使
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
/// 使
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
/// 使
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
}
/// query 便
private func longTailQueryItems(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) }
})
}
/// JSON body 便
private func longTailBodyObject(from request: URLRequest) throws -> [String: Any] {
let data = try XCTUnwrap(request.httpBody)
let object = try JSONSerialization.jsonObject(with: data)
return try XCTUnwrap(object as? [String: Any])
}

View File

@ -309,6 +309,55 @@ private final class MockOrderService: OrderServing {
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

View File

@ -0,0 +1,227 @@
//
// QueueManagementViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class QueueManagementViewModelTests: XCTestCase {
override func setUp() {
super.setUp()
clearQueueDefaults()
}
func testReloadWithoutScenicClearsState() async {
let api = ScenicQueueMock()
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: nil, userId: "77", spots: sampleSpots)
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertNil(viewModel.selectedSpotId)
XCTAssertFalse(viewModel.queueGatePassed)
XCTAssertEqual(viewModel.queueCount, 0)
}
func testReloadWithoutSavedSpotShowsGateAndDoesNotRequestQueue() async {
let api = ScenicQueueMock()
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
XCTAssertEqual(viewModel.scenicSpots.map(\.id), [901, 902])
XCTAssertNil(viewModel.selectedSpotId)
XCTAssertFalse(viewModel.queueGatePassed)
XCTAssertTrue(api.homeRequests.isEmpty)
XCTAssertTrue(api.statsRequests.isEmpty)
}
func testReloadWithSavedSpotLoadsHomeAndStats() async {
saveSelectedSpot()
let api = ScenicQueueMock()
api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001), ticket(id: 1002)], total: 2), time: "2026-06-25 10:01")]
api.statsResponse = ScenicQueueStatsData(queueCount: 5, avgWaitMin: 12.5, time: "2026-06-25 10:02")
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
XCTAssertEqual(viewModel.selectedSpotId, 901)
XCTAssertTrue(viewModel.queueGatePassed)
XCTAssertEqual(viewModel.items.map(\.id), [1001, 1002])
XCTAssertEqual(viewModel.queueCount, 5)
XCTAssertEqual(viewModel.avgWaitMin, 12.5)
XCTAssertEqual(viewModel.lastSyncTimeText, "2026-06-25 10:02")
XCTAssertEqual(api.homeRequests.first?.type, QueueListType.queueing.rawValue)
}
func testStatsFailureKeepsQueueList() async {
saveSelectedSpot()
let api = ScenicQueueMock()
api.homeResponses = [ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1), time: "home-time")]
api.statsError = QueueTestError.sample
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
XCTAssertEqual(viewModel.items.map(\.id), [1001])
XCTAssertEqual(viewModel.queueCount, 0)
XCTAssertEqual(viewModel.lastSyncTimeText, "home-time")
}
func testCallPassFinishRequeueAndUserMark() async throws {
saveSelectedSpot()
let api = ScenicQueueMock()
api.homeResponses = Array(repeating: ScenicQueueHomeData(list: ScenicQueueHomeListBlock(list: [ticket(id: 1001)], total: 1)), count: 8)
let viewModel = QueueManagementViewModel()
await viewModel.reload(api: api, scenicId: 88, userId: "77", spots: sampleSpots)
try await viewModel.callQueue(api: api, id: 1001)
XCTAssertEqual(api.callIds, [1001])
XCTAssertEqual(viewModel.items.first?.isCalled, 1)
try await viewModel.passQueue(api: api, scenicId: 88, userId: "77", id: 1001)
XCTAssertEqual(api.passIds, [1001])
try await viewModel.finishQueue(api: api, scenicId: 88, userId: "77", id: 1001)
XCTAssertEqual(api.finishIds, [1001])
try await viewModel.requeue(api: api, scenicId: 88, userId: "77", id: 1001, operatorId: 77)
XCTAssertEqual(api.requeueRequests.map(\.recordId), [1001])
XCTAssertEqual(api.requeueRequests.map(\.operatorId), [77])
XCTAssertEqual(viewModel.selectedListType, .queueing)
try await viewModel.userMark(
api: api,
scenicId: 88,
userId: "77",
request: ScenicQueueUserMarkRequest(uid: 7001, scenicId: 88, markAsFreelancePhotog: 1, queueBanDays: 14, operatorId: 77)
)
XCTAssertEqual(api.userMarkRequests.first?.uid, 7001)
XCTAssertEqual(api.userMarkRequests.first?.queueBanDays, 14)
}
func testSocketSubscriptionAndParse() {
XCTAssertEqual(ScenicQueueSocketClient.subscriptionPayloads(scenicSpotId: 901), [
#"{"type":306,"params":{"scenic_spot_id":901}}"#,
#"{"type":307,"params":{"scenic_spot_id":901}}"#
])
let message = ScenicQueueSocketClient.parseMessage("""
{"code":100000,"data":{"action":"scenic_queue_ticket_called","params":{"scenic_spot_id":"901","record_id":"1002","operator_uid":"77","event_id":"evt-1"}}}
""")
XCTAssertEqual(message?.code, 100000)
XCTAssertTrue(message?.isScenicQueueEvent == true)
XCTAssertEqual(message?.data?.params?.scenicSpotId, 901)
XCTAssertEqual(message?.data?.params?.recordId, 1002)
XCTAssertEqual(message?.data?.params?.operatorUid, 77)
XCTAssertEqual(message?.data?.params?.eventId, "evt-1")
}
}
private let sampleSpots = [
ScenicSpotItem(id: 901, name: "东门打卡点"),
ScenicSpotItem(id: 902, name: "西门打卡点")
]
private func saveSelectedSpot() {
ScenicQueueSettingsStore.saveSelectedSpot(id: 901, name: "东门打卡点", userId: "77", scenicId: 88)
}
private func clearQueueDefaults() {
let defaults = UserDefaults.standard
[
ScenicQueueLocalSettings.selectedSpotIdKey,
ScenicQueueLocalSettings.selectedSpotNameKey,
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88),
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88),
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901)
].forEach(defaults.removeObject)
}
private func ticket(id: Int64, isCalled: Int = 0) -> ScenicQueueTicket {
ScenicQueueTicket(
id: id,
queueCode: "A\(id)",
mobile: "13800000000",
status: 0,
statusText: "",
waitMin: 5,
aheadCount: 1,
isCalled: isCalled,
createdAt: "2026-06-25 10:00:00",
calledAt: "",
expiredAt: "",
finishedAt: "",
queueCountToday: 2,
uid: 7001
)
}
@MainActor
final class ScenicQueueMock: ScenicQueueServing {
var homeResponses: [ScenicQueueHomeData] = []
var statsResponse = ScenicQueueStatsData()
var statsError: Error?
var settingResponse = ScenicQueueSettingData()
var callIds: [Int64] = []
var passIds: [Int64] = []
var finishIds: [Int64] = []
var requeueRequests: [(recordId: Int64, operatorId: Int)] = []
var userMarkRequests: [ScenicQueueUserMarkRequest] = []
var saveSettingRequests: [ScenicQueueSaveSettingRequest] = []
var homeRequests: [(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int)] = []
var statsRequests: [(scenicId: Int, scenicSpotId: Int)] = []
var qrcodeURL = "https://example.com/qrcode.png"
var logs = ScenicQueueSettingChangeLogData()
func scenicQueueStats(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueStatsData {
statsRequests.append((scenicId, scenicSpotId))
if let statsError { throw statsError }
return statsResponse
}
func scenicQueueHome(scenicId: Int, scenicSpotId: Int, type: Int, page: Int, pageSize: Int) async throws -> ScenicQueueHomeData {
homeRequests.append((scenicId, scenicSpotId, type, page, pageSize))
return homeResponses.isEmpty ? ScenicQueueHomeData() : homeResponses.removeFirst()
}
func socketToken() async throws -> SocketTokenResponse { SocketTokenResponse(socketToken: "token") }
func scenicQueueCall(id: Int64) async throws -> ScenicQueueCallData {
callIds.append(id)
return ScenicQueueCallData(id: id, status: 1, statusText: "已叫号")
}
func scenicQueuePass(id: Int64) async throws -> ScenicQueuePassData {
passIds.append(id)
return ScenicQueuePassData(id: id)
}
func scenicQueueFinish(id: Int64) async throws -> ScenicQueueFinishData {
finishIds.append(id)
return ScenicQueueFinishData(id: id)
}
func scenicQueueRequeueInsertBefore(recordId: Int64, operatorId: Int) async throws {
requeueRequests.append((recordId, operatorId))
}
func scenicQueueUserMark(_ request: ScenicQueueUserMarkRequest) async throws {
userMarkRequests.append(request)
}
func scenicQueueSetting(scenicId: Int, scenicSpotId: Int?) async throws -> ScenicQueueSettingData { settingResponse }
func scenicQueueSaveSetting(_ request: ScenicQueueSaveSettingRequest) async throws {
saveSettingRequests.append(request)
}
func scenicQueueShootQueueQRCode(scenicId: Int, scenicSpotId: Int) async throws -> ScenicQueueShootQueueQRCodeData {
ScenicQueueShootQueueQRCodeData(qrcodeUrl: qrcodeURL)
}
func scenicQueueSettingChangeLog(scenicId: Int, scenicSpotId: Int?, page: Int, pageSize: Int) async throws -> ScenicQueueSettingChangeLogData {
logs
}
}
private enum QueueTestError: Error {
case sample
}

View File

@ -0,0 +1,182 @@
//
// ScenicQueueSettingsViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class ScenicQueueSettingsViewModelTests: XCTestCase {
override func setUp() {
super.setUp()
clearQueueSettingsDefaults()
}
func testLoadSelectsFirstSpotAndAppliesLocalSnapshotWhenServerMissing() async {
let snapshot = ScenicQueueSettingsSnapshot(
shootMinute: 4,
shootSecond: 15,
firstAheadCount: 6,
firstSms: false,
firstPhone: true,
secondAheadCount: 2,
secondSms: true,
secondPhone: false,
broadcastIntervalSec: 55,
countdownThresholdSec: 25,
queueDistanceMeter: 8_880,
queueTakeLimit: 11,
missCallRequeueOffset: 5,
showStartShootingButton: false,
autoCallAheadCount: 3,
quickCallButtonEnabled: true,
prepareCallButtonEnabled: true,
businessOpen: false,
businessStartTime: "09:30",
businessEndTime: "18:45"
)
ScenicQueueSettingsStore.saveSettingsSnapshot(snapshot, userId: "77", scenicId: 88, spotId: 901)
let api = ScenicQueueMock()
api.settingResponse = ScenicQueueSettingData(exists: false)
let viewModel = ScenicQueueSettingsViewModel()
await viewModel.load(api: api, scenicId: 88, userId: "77", spots: settingsSpots)
XCTAssertEqual(viewModel.selectedSpotId, 901)
XCTAssertEqual(viewModel.photoEstimateMin, "4")
XCTAssertEqual(viewModel.photoEstimateSec, "15")
XCTAssertEqual(viewModel.firstAhead, "6")
XCTAssertTrue(viewModel.firstPhone)
XCTAssertEqual(viewModel.secondAhead, "2")
XCTAssertEqual(viewModel.broadcastIntervalSec, "55")
XCTAssertEqual(viewModel.countdownThresholdSec, "25")
XCTAssertEqual(viewModel.maxQueueRangeKm, "8.88")
XCTAssertEqual(viewModel.localQueueLimit, "11")
XCTAssertEqual(viewModel.localPassDelay, "5")
XCTAssertFalse(viewModel.showStartShootingButton)
XCTAssertEqual(viewModel.autoCallAheadCount, "3")
XCTAssertTrue(viewModel.quickCallButtonEnabled)
XCTAssertTrue(viewModel.prepareCallButtonEnabled)
XCTAssertFalse(viewModel.queueOpen)
}
func testSaveNormalizesBodyAndPersistsSnapshot() async throws {
let api = ScenicQueueMock()
let viewModel = ScenicQueueSettingsViewModel()
viewModel.scenicSpots = settingsSpots
viewModel.selectedSpotId = 901
viewModel.photoEstimateMin = "1"
viewModel.photoEstimateSec = "20"
viewModel.firstAhead = "5"
viewModel.secondAhead = "2"
viewModel.broadcastIntervalSec = "45"
viewModel.countdownThresholdSec = "20"
viewModel.localQueueLimit = "7"
viewModel.localPassDelay = "3"
viewModel.showStartShootingButton = false
viewModel.autoCallAheadCount = "4"
viewModel.quickCallButtonEnabled = true
viewModel.prepareCallButtonEnabled = true
viewModel.queueOpen = false
viewModel.customTtsText = " {number} 请到拍摄点 "
viewModel.presetVoices = ["预设播报", " ", "第二条"]
viewModel.businessStartTime = ScenicQueueSettingsViewModel.businessTime(hour: 11, minute: 0)
viewModel.businessEndTime = ScenicQueueSettingsViewModel.businessTime(hour: 21, minute: 30)
viewModel.updateMaxQueueRange("12.3")
try await viewModel.save(api: api, scenicId: 88, userId: "77")
let request = try XCTUnwrap(api.saveSettingRequests.first)
XCTAssertEqual(request.scenicId, 88)
XCTAssertEqual(request.scenicSpotId, 901)
XCTAssertEqual(request.photoEstimateMin, 1)
XCTAssertEqual(request.photoEstimateSec, 20)
XCTAssertEqual(request.queueDistanceMeter, 12_300)
XCTAssertEqual(request.queueTakeLimit, 7)
XCTAssertEqual(request.missCallRequeueOffset, 3)
XCTAssertEqual(request.showStartShootButton, 0)
XCTAssertEqual(request.autoCallNextCount, 4)
XCTAssertEqual(request.businessStartTime, "11:00")
XCTAssertEqual(request.businessEndTime, "21:30")
XCTAssertEqual(request.voiceBroadcasts.map(\.content), ["预设播报", "第二条"])
XCTAssertEqual(request.status, 0)
XCTAssertEqual(request.remark, "{number} 请到拍摄点")
XCTAssertEqual(ScenicQueueSettingsStore.selectedSpotId(userId: "77", scenicId: 88), 901)
let snapshot = try XCTUnwrap(ScenicQueueSettingsStore.settingsSnapshot(userId: "77", scenicId: 88, spotId: 901))
XCTAssertEqual(snapshot.queueDistanceMeter, 12_300)
XCTAssertEqual(snapshot.autoCallAheadCount, 4)
XCTAssertTrue(snapshot.quickCallButtonEnabled)
}
func testValidationRejectsInvalidValues() async {
let api = ScenicQueueMock()
let viewModel = ScenicQueueSettingsViewModel()
viewModel.selectedSpotId = 901
viewModel.photoEstimateMin = "0"
viewModel.photoEstimateSec = "0"
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
viewModel.photoEstimateSec = "20"
viewModel.firstAhead = "1"
viewModel.secondAhead = "2"
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
viewModel.firstAhead = "2"
viewModel.secondAhead = "1"
viewModel.broadcastIntervalSec = "30"
await XCTAssertThrowsErrorAsync(try await viewModel.save(api: api, scenicId: 88, userId: "77"))
}
func testFetchQRCodeRejectsEmptyURL() async {
let api = ScenicQueueMock()
api.qrcodeURL = ""
let viewModel = ScenicQueueSettingsViewModel()
viewModel.selectedSpotId = 901
await XCTAssertThrowsErrorAsync(try await viewModel.fetchQRCode(api: api, scenicId: 88))
}
}
private let settingsSpots = [
ScenicSpotItem(id: 901, name: "东门打卡点"),
ScenicSpotItem(id: 902, name: "西门打卡点")
]
private func clearQueueSettingsDefaults() {
let defaults = UserDefaults.standard
[
ScenicQueueLocalSettings.selectedSpotIdKey,
ScenicQueueLocalSettings.selectedSpotNameKey,
ScenicQueueLocalSettings.customTtsTextKey,
ScenicQueueLocalSettings.photoEstimateSecondsKey,
ScenicQueueLocalSettings.broadcastIntervalSecondsKey,
ScenicQueueLocalSettings.countdownThresholdSecondsKey,
ScenicQueueLocalSettings.showStartShootingButtonKey,
ScenicQueueLocalSettings.autoCallAheadCountKey,
ScenicQueueLocalSettings.quickCallButtonEnabledKey,
ScenicQueueLocalSettings.prepareCallButtonEnabledKey,
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotIdKey, userId: "77", scenicId: 88),
ScenicQueueSettingsStore.scopedKey(base: ScenicQueueLocalSettings.selectedSpotNameKey, userId: "77", scenicId: 88),
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.settingsSnapshotKey, userId: "77", scenicId: 88, spotId: 901),
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.customTtsTextKey, userId: "77", scenicId: 88, spotId: 901),
ScenicQueueSettingsStore.scopedSpotKey(base: ScenicQueueLocalSettings.presetVoicesKey, userId: "77", scenicId: 88, spotId: 901)
].forEach(defaults.removeObject)
}
private func XCTAssertThrowsErrorAsync<T>(
_ expression: @autoclosure () async throws -> T,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
try await expression()
XCTFail("Expected error to be thrown", file: file, line: line)
} catch {
}
}

View File

@ -0,0 +1,212 @@
//
// ScenicSettlementViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class ScenicSettlementViewModelTests: XCTestCase {
///
func testSettlementLoadExcludesExistingScenics() async {
let permissionAPI = ScenicSettlementPermissionMock()
permissionAPI.scenicList = ScenicListAllResponse(total: 3, list: [
ScenicListItem(id: 1, name: "已有景区"),
ScenicListItem(id: 2, name: "新增景区"),
ScenicListItem(id: 3, name: "另一个景区")
])
let viewModel = ScenicSettlementViewModel()
await viewModel.load(
api: permissionAPI,
existingScenics: [BusinessScope(id: 1, name: "已有景区", kind: .scenic)]
)
XCTAssertEqual(viewModel.options.map(\.id), [2, 3])
}
/// 0
func testSettlementAmountValidation() {
let viewModel = ScenicSettlementViewModel()
XCTAssertEqual(viewModel.amountValidation, .empty)
viewModel.amountText = "0"
XCTAssertEqual(viewModel.amountValidation, .notPositive)
viewModel.amountText = "-1"
XCTAssertEqual(viewModel.amountValidation, .invalidFormat)
viewModel.amountText = "1.234"
XCTAssertEqual(viewModel.amountValidation, .invalidFormat)
viewModel.amountText = "12.30"
XCTAssertEqual(viewModel.amountValidation, .valid)
}
/// body
func testSettlementSubmitMultipleScenicsAndClearsForm() async {
let submitAPI = ScenicSettlementSubmitMock()
let viewModel = ScenicSettlementViewModel()
viewModel.options = [
ScenicSettlementOption(id: 3, name: "三号", selected: false),
ScenicSettlementOption(id: 2, name: "二号", selected: false)
]
viewModel.toggleScenic(id: 3)
viewModel.toggleScenic(id: 2)
viewModel.amountText = "99"
viewModel.remarkText = " 活动结算 "
let success = await viewModel.submit(api: submitAPI)
XCTAssertTrue(success)
XCTAssertEqual(submitAPI.requests, [
ScenicSettlementSubmitRequest(scenicId: 2, applyAmount: "99.00", applyRemark: "活动结算"),
ScenicSettlementSubmitRequest(scenicId: 3, applyAmount: "99.00", applyRemark: "活动结算")
])
XCTAssertTrue(viewModel.selectedScenicIds.isEmpty)
XCTAssertEqual(viewModel.amountText, "")
XCTAssertEqual(viewModel.remarkText, "")
XCTAssertEqual(viewModel.message, "提交成功,等待审核")
}
///
func testSettlementSubmitFailureKeepsForm() async {
let submitAPI = ScenicSettlementSubmitMock()
submitAPI.errorAfterRequestCount = 1
let viewModel = ScenicSettlementViewModel()
viewModel.options = [
ScenicSettlementOption(id: 1, name: "一号", selected: false),
ScenicSettlementOption(id: 2, name: "二号", selected: false)
]
viewModel.toggleScenic(id: 1)
viewModel.toggleScenic(id: 2)
viewModel.amountText = "10"
let success = await viewModel.submit(api: submitAPI)
XCTAssertFalse(success)
XCTAssertEqual(viewModel.selectedScenicIds, [1, 2])
XCTAssertEqual(viewModel.amountText, "10")
XCTAssertEqual(viewModel.message, TestError.sample.localizedDescription)
}
///
func testReviewLoadSuccessAggregatesRecords() async {
let permissionAPI = ScenicSettlementPermissionMock()
permissionAPI.scenicPendings = ScenicApplicationPendingsResponse(items: [
ScenicApplicationPendingResponse(id: 1, scenicName: "景区", status: 1)
])
permissionAPI.roleApplies = [
RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 2)
]
let viewModel = ScenicSettlementReviewViewModel()
await viewModel.load(api: permissionAPI)
XCTAssertEqual(viewModel.scenicApplications.count, 1)
XCTAssertEqual(viewModel.roleApplications.count, 1)
XCTAssertEqual(viewModel.pendingCount, 1)
XCTAssertFalse(viewModel.loadFailedAll)
}
///
func testReviewSingleChannelFailureKeepsOtherChannel() async {
let permissionAPI = ScenicSettlementPermissionMock()
permissionAPI.scenicPendingsError = TestError.sample
permissionAPI.roleApplies = [
RoleApplyPendingResponse(id: 2, roleId: 1, roleName: "摄影师", status: 1)
]
let viewModel = ScenicSettlementReviewViewModel()
await viewModel.load(api: permissionAPI)
XCTAssertTrue(viewModel.scenicLoadFailed)
XCTAssertFalse(viewModel.roleLoadFailed)
XCTAssertFalse(viewModel.loadFailedAll)
XCTAssertEqual(viewModel.roleApplications.count, 1)
}
///
func testReviewBothChannelsFailureMarksFullFailure() async {
let permissionAPI = ScenicSettlementPermissionMock()
permissionAPI.scenicPendingsError = TestError.sample
permissionAPI.roleAppliesError = TestError.sample
let viewModel = ScenicSettlementReviewViewModel()
await viewModel.load(api: permissionAPI)
XCTAssertTrue(viewModel.loadFailedAll)
XCTAssertTrue(viewModel.scenicApplications.isEmpty)
XCTAssertTrue(viewModel.roleApplications.isEmpty)
}
///
func testReviewStatusMapping() {
let viewModel = ScenicSettlementReviewViewModel()
XCTAssertEqual(viewModel.statusText(1), "待审核")
XCTAssertEqual(viewModel.statusText(2), "已通过")
XCTAssertEqual(viewModel.statusText(3), "已驳回")
XCTAssertEqual(viewModel.statusText(9), "已取消")
XCTAssertEqual(viewModel.statusText(99), "未知")
XCTAssertEqual(viewModel.statusIcon(2), "checkmark.circle.fill")
}
}
@MainActor
private final class ScenicSettlementPermissionMock: ScenicPermissionServing {
var scenicList = ScenicListAllResponse(total: 0, list: [])
var scenicListError: Error?
var scenicPendings = ScenicApplicationPendingsResponse()
var scenicPendingsError: Error?
var roleApplies: [RoleApplyPendingResponse] = []
var roleAppliesError: Error?
func scenicListAll() async throws -> ScenicListAllResponse {
if let scenicListError { throw scenicListError }
return scenicList
}
func areas() async throws -> [ScenicAreaNode] { [] }
func scenicApplicationPendingAll() async throws -> ScenicApplicationPendingsResponse {
if let scenicPendingsError { throw scenicPendingsError }
return scenicPendings
}
func scenicSubmit(_ request: ScenicApplicationSubmitRequest) async throws {}
func scenicApplicationUploadPlaceholder(_ items: [ScenicApplicationUploadPlaceholder]) async throws {}
func roleApplyAll() async throws -> [RoleApplyPendingResponse] {
if let roleAppliesError { throw roleAppliesError }
return roleApplies
}
func roleApplySubmit(roleId: Int, scenicIds: [Int]) async throws {}
}
@MainActor
private final class ScenicSettlementSubmitMock: ScenicSettlementServing {
var requests: [ScenicSettlementSubmitRequest] = []
var errorAfterRequestCount: Int?
func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws {
requests.append(request)
if let errorAfterRequestCount, requests.count >= errorAfterRequestCount {
throw TestError.sample
}
}
}
private enum TestError: LocalizedError {
case sample
var errorDescription: String? {
"测试错误"
}
}

View File

@ -0,0 +1,125 @@
//
// WithdrawalAuditViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/25.
//
import XCTest
@testable import suixinkan
@MainActor
///
final class WithdrawalAuditViewModelTests: XCTestCase {
///
func testLoadFilterAndLoadMore() async throws {
let api = WithdrawalAuditWalletMock()
api.withdrawResponses = [
WalletWithdrawListResponse(total: 3, item: [
try record(id: 1, status: "审核中"),
try record(id: 2, status: "已到账")
]),
WalletWithdrawListResponse(total: 3, item: [
try record(id: 3, status: "待打款")
])
]
let viewModel = WithdrawalAuditViewModel()
await viewModel.reload(api: api)
XCTAssertEqual(api.withdrawPages, [1])
XCTAssertEqual(viewModel.totalCount, 3)
XCTAssertEqual(viewModel.processingCount, 1)
XCTAssertEqual(viewModel.completedCount, 1)
XCTAssertTrue(viewModel.hasMore)
viewModel.selectFilter(.processing)
XCTAssertEqual(viewModel.filteredRecords.map(\.id), [1])
await viewModel.loadMore(api: api)
XCTAssertEqual(api.withdrawPages, [1, 2])
XCTAssertEqual(viewModel.records.map(\.id), [1, 2, 3])
XCTAssertEqual(viewModel.processingCount, 2)
XCTAssertFalse(viewModel.hasMore)
}
///
func testReloadFailureClearsStaleRecordsAndPaging() async throws {
let api = WithdrawalAuditWalletMock()
api.withdrawResponses = [
WalletWithdrawListResponse(total: 2, item: [try record(id: 1, status: "审核中")])
]
let viewModel = WithdrawalAuditViewModel()
await viewModel.reload(api: api)
XCTAssertEqual(viewModel.records.map(\.id), [1])
XCTAssertTrue(viewModel.hasMore)
api.withdrawError = TestError.sample
await viewModel.reload(api: api)
XCTAssertTrue(viewModel.records.isEmpty)
XCTAssertFalse(viewModel.hasMore)
XCTAssertTrue(viewModel.loadFailed)
XCTAssertEqual(viewModel.loadFailureReason, TestError.sample.localizedDescription)
}
///
func testEmptyListAndLastPageDoNotLoadMore() async {
let api = WithdrawalAuditWalletMock()
api.withdrawResponses = [WalletWithdrawListResponse(total: 0, item: [])]
let viewModel = WithdrawalAuditViewModel()
await viewModel.reload(api: api)
await viewModel.loadMore(api: api)
XCTAssertTrue(viewModel.records.isEmpty)
XCTAssertEqual(api.withdrawPages, [1])
XCTAssertFalse(viewModel.hasMore)
}
private func record(id: Int64, amount: String = "10", status: String) throws -> WalletWithdrawRecord {
let data = """
{"id":\(id),"amount":"\(amount)","created_at":"2026-06-25 10:00","status_label":"\(status)","expected_at":"2026-06-26","audit_time":"2026-06-25 11:00","completed_at":""}
""".data(using: .utf8)!
return try JSONDecoder().decode(WalletWithdrawRecord.self, from: data)
}
}
@MainActor
private final class WithdrawalAuditWalletMock: WalletServing {
var withdrawResponses: [WalletWithdrawListResponse] = []
var withdrawError: Error?
private(set) var withdrawPages: [Int] = []
func walletSummary(type: Int) async throws -> WalletSummaryResponse { WalletSummaryResponse() }
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse { WalletEarningDetailResponse() }
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
withdrawPages.append(page)
if let withdrawError { throw withdrawError }
return withdrawResponses.isEmpty ? WalletWithdrawListResponse() : withdrawResponses.removeFirst()
}
func withdrawInfo() async throws -> WithdrawInfoResponse {
let data = #"{"amount_withdrawable":"0","min_withdraw_amount":"1","max_single_withdraw_amount":"100","max_daily_withdraw_amount":"100","user_phone":"13800000000","bank_card":{},"withdraw_info":[]}"#.data(using: .utf8)!
return try JSONDecoder().decode(WithdrawInfoResponse.self, from: data)
}
func withdrawSendSms() async throws {}
func withdrawApply(amount: String, smsCode: String) async throws {}
func bankCardInfo() async throws -> BankCardInfoResponse { BankCardInfoResponse(bankCard: nil) }
func bankList() async throws -> BankListResponse { BankListResponse() }
func areas() async throws -> [AreaNode] { [] }
func bankCardVerifyCode() async throws {}
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {}
func pointOverview(staffId: Int) async throws -> PointOverviewResponse { PointOverviewResponse() }
func pointWithdrawApply(points: Int, remark: String) async throws {}
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { PointWithdrawListResponse() }
}
private enum TestError: LocalizedError {
case sample
var errorDescription: String? {
"测试错误"
}
}