新增项目、排期与邀请模块,并接入首页路由
将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -546,6 +546,11 @@ private final class MockAssetsUploader: OSSUploadServing {
|
||||
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传项目图片。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传打卡点图片。
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
|
||||
|
||||
@ -34,12 +34,12 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
let menus = [
|
||||
menu("task_management_editor"),
|
||||
menu("pm_manager")
|
||||
menu("project_edit")
|
||||
]
|
||||
|
||||
let uris = store.load(menuItems: menus)
|
||||
|
||||
XCTAssertEqual(uris, ["task_management_editor", "pm_manager"])
|
||||
XCTAssertEqual(uris, ["task_management_editor", "project_edit"])
|
||||
}
|
||||
|
||||
/// 测试添加常用应用时会按同义 URI 去重。
|
||||
@ -60,9 +60,9 @@ final class HomeCommonMenuStoreTests: XCTestCase {
|
||||
let defaults = makeIsolatedDefaults()
|
||||
let store = HomeCommonMenuStore(defaults: defaults)
|
||||
|
||||
let uris = store.remove("pm", current: ["pm_manager", "location_report"])
|
||||
let uris = store.remove("pm", current: ["project_edit", "pm_manager", "location_report"])
|
||||
|
||||
XCTAssertEqual(uris, ["location_report"])
|
||||
XCTAssertEqual(uris, ["pm_manager", "location_report"])
|
||||
}
|
||||
|
||||
/// 创建测试菜单实体。
|
||||
|
||||
@ -46,6 +46,13 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report_history", title: ""), .destination(.locationReportHistory))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "project_edit", title: ""), .destination(.projectManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "schedule_management", title: ""), .destination(.scheduleManagement))
|
||||
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))
|
||||
}
|
||||
|
||||
/// 测试已知但未迁移的首页路由会进入安全占位。
|
||||
@ -90,10 +97,11 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
|
||||
/// 测试 canonicalURI 会使用当前权限可用的同义 URI。
|
||||
func testCanonicalURIUsesAvailableAlias() {
|
||||
let available: Set<String> = ["task_management_editor", "photographer_invite", "pm_manager", "payment_qr"]
|
||||
let available: Set<String> = ["task_management_editor", "photographer_invite", "project_edit", "pm_manager", "payment_qr"]
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "task_management", availableURIs: available), "task_management_editor")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "registration_invitation", availableURIs: available), "photographer_invite")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm", availableURIs: available), "pm_manager")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm", availableURIs: available), "project_edit")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "pm_manager", availableURIs: available), "pm_manager")
|
||||
XCTAssertEqual(HomeMenuRouter.canonicalURI(for: "payment_code", availableURIs: available), "payment_qr")
|
||||
}
|
||||
|
||||
@ -101,7 +109,8 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
func testMenuAliasKeyGroupsDuplicateAndroidEntries() {
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "task_management_editor"), "task_management")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "photographer_invite"), "registration_invitation")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "pm_manager"), "pm")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "project_edit"), "pm")
|
||||
XCTAssertEqual(HomeMenuRouter.menuAliasKey(for: "pm_manager"), "pm_manager")
|
||||
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")
|
||||
@ -173,11 +182,21 @@ final class HomeMenuRouterTests: XCTestCase {
|
||||
XCTAssertTrue(uris.contains("sample_management"))
|
||||
XCTAssertTrue(uris.contains("checkin_points"))
|
||||
XCTAssertTrue(uris.contains("location_report"))
|
||||
XCTAssertTrue(uris.contains("pm"))
|
||||
XCTAssertTrue(uris.contains("pm_manager"))
|
||||
XCTAssertTrue(uris.contains("schedule_management"))
|
||||
XCTAssertTrue(uris.contains("registration_invitation"))
|
||||
XCTAssertTrue(uris.contains("invite_record"))
|
||||
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))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "checkin_points", title: ""), .destination(.punchPointList))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "location_report", title: ""), .destination(.locationReport))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm", title: ""), .destination(.projectManagement))
|
||||
XCTAssertEqual(HomeMenuRouter.resolve(uri: "pm_manager", title: ""), .destination(.pmProjectManagement))
|
||||
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))
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
@ -77,7 +77,7 @@ final class HomeViewModelTests: XCTestCase {
|
||||
|
||||
viewModel.buildMenus(from: permissions, currentRoleId: 1)
|
||||
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["deposit_order_detail", "pm_manager", "payment_code"])
|
||||
XCTAssertEqual(viewModel.menuItems.map(\.uri), ["pm", "deposit_order_detail", "pm_manager", "payment_code"])
|
||||
}
|
||||
|
||||
/// 测试菜单按旧工程 preferred order 排序。
|
||||
|
||||
154
suixinkanTests/Invite/InviteTests.swift
Normal file
154
suixinkanTests/Invite/InviteTests.swift
Normal file
@ -0,0 +1,154 @@
|
||||
//
|
||||
// InviteTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 邀请 API 和 ViewModel 测试。
|
||||
final class InviteTests: XCTestCase {
|
||||
/// 测试邀请 API path 和分页参数。
|
||||
func testInviteAPIUsesExpectedRequests() async throws {
|
||||
let session = InviteRecordingURLSession(responses: [Self.inviteInfoResponse, Self.inviteUsersResponse])
|
||||
let api = InviteAPI(client: APIClient(session: session))
|
||||
|
||||
let info = try await api.inviteInfo()
|
||||
let users = try await api.inviteUserList(page: 0, pageSize: 0)
|
||||
|
||||
XCTAssertEqual(info.inviteCode, "ABC123")
|
||||
XCTAssertEqual(users.first?.id, 1)
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/invite-info",
|
||||
"/api/yf-handset-app/photog/invite/user-list"
|
||||
])
|
||||
XCTAssertEqual(inviteQueryItems(from: session.requests[1])["page"], "1")
|
||||
XCTAssertEqual(inviteQueryItems(from: session.requests[1])["page_size"], "1")
|
||||
}
|
||||
|
||||
/// 测试邀请页加载信息并生成二维码。
|
||||
func testInviteViewModelLoadsInfoAndGeneratesQRCode() async {
|
||||
let api = FakeInviteService()
|
||||
let viewModel = PhotographerInviteViewModel()
|
||||
|
||||
await viewModel.reload(api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.inviteCode, "ABC123")
|
||||
XCTAssertEqual(viewModel.rules, ["规则1"])
|
||||
XCTAssertNotNil(viewModel.qrImage)
|
||||
}
|
||||
|
||||
/// 测试邀请记录加载邀请用户和钱包汇总。
|
||||
func testInviteRecordLoadsInviteRowsAndSummary() async {
|
||||
let inviteAPI = FakeInviteService()
|
||||
let walletAPI = FakeWalletService()
|
||||
let viewModel = InviteRecordViewModel()
|
||||
|
||||
await viewModel.reload(inviteAPI: inviteAPI, walletAPI: walletAPI, refresh: true)
|
||||
|
||||
XCTAssertEqual(viewModel.displayRows.first?.title, "摄影师")
|
||||
XCTAssertEqual(viewModel.totalRewardText, "¥ 100.00")
|
||||
XCTAssertEqual(walletAPI.summaryTypes, [2])
|
||||
}
|
||||
|
||||
/// 测试奖励分段调用钱包收益明细。
|
||||
func testInviteRecordRewardTabLoadsWalletDetails() async {
|
||||
let inviteAPI = FakeInviteService()
|
||||
let walletAPI = FakeWalletService()
|
||||
let viewModel = InviteRecordViewModel()
|
||||
|
||||
await viewModel.selectTab(.reward, inviteAPI: inviteAPI, walletAPI: walletAPI)
|
||||
|
||||
XCTAssertEqual(walletAPI.earningPages, [1])
|
||||
XCTAssertNil(viewModel.errorMessage, viewModel.errorMessage ?? "")
|
||||
XCTAssertEqual(viewModel.displayRows.first?.amount, "8.00")
|
||||
}
|
||||
|
||||
private static let inviteInfoResponse = Data(#"{"code":100000,"message":"ok","data":{"enable_invite":"1","invite_code":"ABC123","invite_url":"https://invite.example.com","description":["规则1"]}}"#.utf8)
|
||||
private static let inviteUsersResponse = Data(#"{"code":100000,"message":"ok","data":[{"id":"1","real_name":"摄影师","phone":"13800000000","avatar":"","created_at":"2026-06-24","invite_level":"1"}]}"#.utf8)
|
||||
}
|
||||
|
||||
/// 邀请 API 测试 URLSession。
|
||||
private final class InviteRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化测试 Session。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.removeFirst()
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请服务测试替身。
|
||||
@MainActor
|
||||
private final class FakeInviteService: InviteServing {
|
||||
func inviteInfo() async throws -> InviteInfoResponse {
|
||||
try JSONDecoder().decode(InviteInfoResponse.self, from: Data(#"{"enable_invite":"1","invite_code":"ABC123","invite_url":"https://invite.example.com","description":["规则1"]}"#.utf8))
|
||||
}
|
||||
|
||||
func inviteUserList(page: Int, pageSize: Int) async throws -> [InviteUserItem] {
|
||||
[try JSONDecoder().decode(InviteUserItem.self, from: Data(#"{"id":"1","real_name":"摄影师","phone":"13800000000","avatar":"","created_at":"2026-06-24","invite_level":"1"}"#.utf8))]
|
||||
}
|
||||
}
|
||||
|
||||
/// 钱包服务测试替身。
|
||||
@MainActor
|
||||
private final class FakeWalletService: WalletServing {
|
||||
var summaryTypes: [Int] = []
|
||||
var earningPages: [Int] = []
|
||||
|
||||
func walletSummary(type: Int) async throws -> WalletSummaryResponse {
|
||||
summaryTypes.append(type)
|
||||
return WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "20.00", amountWithdrawable: "50.00")
|
||||
}
|
||||
|
||||
func walletEarningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> WalletEarningDetailResponse {
|
||||
earningPages.append(page)
|
||||
let item = WalletEarningDetailItem(
|
||||
id: 1,
|
||||
amount: "8.00",
|
||||
type: 2,
|
||||
typeLabel: "邀请奖励",
|
||||
orderNumberSuffix: "0001",
|
||||
createdAt: "2026-06-24"
|
||||
)
|
||||
return WalletEarningDetailResponse(
|
||||
totalAmount: "8.00",
|
||||
total: 1,
|
||||
list: [WalletEarningDetailGroup(date: "2026-06-24", dayAmount: "8.00", items: [item])]
|
||||
)
|
||||
}
|
||||
|
||||
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse { WalletWithdrawListResponse() }
|
||||
func withdrawInfo() async throws -> WithdrawInfoResponse { try JSONDecoder().decode(WithdrawInfoResponse.self, from: Data("{}".utf8)) }
|
||||
func withdrawSendSms() async throws {}
|
||||
func withdrawApply(amount: String, smsCode: String) async throws {}
|
||||
func bankCardInfo() async throws -> BankCardInfoResponse { try JSONDecoder().decode(BankCardInfoResponse.self, from: Data("{}".utf8)) }
|
||||
func bankList() async throws -> BankListResponse { BankListResponse(banks: []) }
|
||||
func areas() async throws -> [AreaNode] { [] }
|
||||
func bankCardVerifyCode() async throws {}
|
||||
func updateBankInfo(_ request: UpdateBankInfoRequest) async throws {}
|
||||
func pointOverview(staffId: Int) async throws -> PointOverviewResponse { try JSONDecoder().decode(PointOverviewResponse.self, from: Data("{}".utf8)) }
|
||||
func pointWithdrawApply(points: Int, remark: String) async throws {}
|
||||
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse { PointWithdrawListResponse(total: 0, list: []) }
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func inviteQueryItems(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) }
|
||||
})
|
||||
}
|
||||
@ -361,6 +361,11 @@ private final class MockOSSUploader: OSSUploadServing {
|
||||
avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传项目图片。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
}
|
||||
|
||||
/// 模拟上传打卡点图片。
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
avatarURL
|
||||
|
||||
150
suixinkanTests/Projects/ProjectAPITests.swift
Normal file
150
suixinkanTests/Projects/ProjectAPITests.swift
Normal file
@ -0,0 +1,150 @@
|
||||
//
|
||||
// ProjectAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 项目 API 测试,覆盖摄影师项目和店铺项目请求。
|
||||
final class ProjectAPITests: XCTestCase {
|
||||
/// 测试摄影师项目列表接口 path 和 query。
|
||||
func testProjectListUsesExpectedPathAndQuery() async throws {
|
||||
let session = ProjectRecordingURLSession(data: Self.projectListResponse)
|
||||
let api = ProjectAPI(client: APIClient(session: session))
|
||||
|
||||
let payload = try await api.projectList(scenicId: 9, name: " 亲子 ", page: 0, pageSize: 0)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/project/list")
|
||||
let query = projectQueryItems(from: request)
|
||||
XCTAssertEqual(query["scenic_id"], "9")
|
||||
XCTAssertEqual(query["name"], "亲子")
|
||||
XCTAssertEqual(query["page"], "1")
|
||||
XCTAssertEqual(query["page_size"], "1")
|
||||
XCTAssertEqual(payload.list.first?.id, 88)
|
||||
}
|
||||
|
||||
/// 测试项目详情和删除接口路径正确。
|
||||
func testProjectDetailAndDeleteUseExpectedPath() async throws {
|
||||
let session = ProjectRecordingURLSession(responses: [Self.projectDetailResponse, Self.emptyResponse])
|
||||
let api = ProjectAPI(client: APIClient(session: session))
|
||||
|
||||
let detail = try await api.projectDetail(id: 88)
|
||||
try await api.deleteProject(id: 88)
|
||||
|
||||
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/project/info-view")
|
||||
XCTAssertEqual(projectQueryItems(from: session.requests[0])["id"], "88")
|
||||
XCTAssertEqual(detail.materialNum, 12)
|
||||
XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/project/delete")
|
||||
XCTAssertEqual(try bodyObject(from: session.requests[1])["id"] as? Int, 88)
|
||||
}
|
||||
|
||||
/// 测试创建和编辑摄影师项目请求体。
|
||||
func testCreateAndEditProjectUseExpectedBody() async throws {
|
||||
let session = ProjectRecordingURLSession(data: Self.emptyResponse)
|
||||
let api = ProjectAPI(client: APIClient(session: session))
|
||||
let request = ProjectCreateRequest(
|
||||
id: 7,
|
||||
type: 11,
|
||||
scenicId: 9,
|
||||
name: "项目",
|
||||
price: "99.00",
|
||||
otPrice: "199.00",
|
||||
coverProject: "https://cdn/p.jpg",
|
||||
coverCarousel: ["https://cdn/a.jpg"],
|
||||
description: "描述",
|
||||
attrLabel: ["亲子"],
|
||||
extra: ProjectCreateExtra(priceDeposit: "10.00", materialNum: 1, photoNum: 2, videoNum: 3, scenicSpotId: [6], photogUid: [100])
|
||||
)
|
||||
|
||||
try await api.createProject(request)
|
||||
try await api.editProject(request)
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/project/create",
|
||||
"/api/yf-handset-app/photog/project/edit"
|
||||
])
|
||||
let body = try bodyObject(from: session.requests[0])
|
||||
XCTAssertEqual(body["scenic_id"] as? Int, 9)
|
||||
XCTAssertEqual(body["cover_project"] as? String, "https://cdn/p.jpg")
|
||||
XCTAssertEqual((body["extra"] as? [String: Any])?["photo_num"] as? Int, 2)
|
||||
}
|
||||
|
||||
/// 测试店铺项目接口 path、query 和 body。
|
||||
func testStoreManagerRequestsUseExpectedPathAndBody() async throws {
|
||||
let session = ProjectRecordingURLSession(responses: [
|
||||
Self.scenicListResponse,
|
||||
Self.projectListResponse,
|
||||
Self.projectDetailResponse,
|
||||
Self.emptyResponse,
|
||||
Self.emptyResponse
|
||||
])
|
||||
let api = ProjectAPI(client: APIClient(session: session))
|
||||
|
||||
_ = try await api.storeManagerScenicList(userId: "100")
|
||||
_ = try await api.storeManagerProjectList(userId: "100", page: 0, pageSize: 0)
|
||||
_ = try await api.storeManagerProjectDetail(id: 88)
|
||||
try await api.storeManagerCreate(StoreManagerCreateRequest(name: "店铺项目", storeId: nil, type: 19, description: "描述", coverProject: "u", coverCarousel: [], projectRule: nil, scenicId: [9], settleSpotNum: 1, price: 100, priceMaterial: 1, pricePhoto: 2, priceVideo: 3, priceMaterialAll: nil, packageList: nil, userId: 100, singleSpotMaterialNum: 1, singleSpotPhotoNum: 2, singleSpotVideoNum: 3))
|
||||
try await api.storeManagerOfflineCreate(StoreManagerOfflineCreateRequest(name: "押金", scenicId: 9, storeId: 3, description: "描述", price: 20, coverProject: "u", coverCarousel: []))
|
||||
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/app/store-manager/scenic-list",
|
||||
"/api/app/store-manager/list",
|
||||
"/api/app/store-manager/detail",
|
||||
"/api/app/store-manager/create",
|
||||
"/api/app/store-manager/offline-create"
|
||||
])
|
||||
XCTAssertEqual(projectQueryItems(from: session.requests[0])["user_id"], "100")
|
||||
XCTAssertEqual(projectQueryItems(from: session.requests[1])["page"], "1")
|
||||
XCTAssertEqual((try bodyObject(from: session.requests[3]))["settle_spot_num"] as? Int, 1)
|
||||
XCTAssertEqual((try bodyObject(from: session.requests[4]))["store_id"] as? Int, 3)
|
||||
}
|
||||
|
||||
private static let emptyResponse = Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8)
|
||||
private static let projectListResponse = Data(#"{"code":100000,"message":"ok","data":{"total":"2","list":[{"id":"88","type":"11","type_name":"旅拍","status":"1","status_name":"运营中","name":"亲子旅拍","cover_project":"https://cdn/p.jpg","price":"99.00","ot_price":199,"price_deposit":"10","attr_label":"亲子,航拍"}]}}"#.utf8)
|
||||
private static let projectDetailResponse = Data(#"{"code":100000,"message":"ok","data":{"id":"88","scenic_id":"9","cover_project":"https://cdn/p.jpg","cover_carousel":["https://cdn/a.jpg"],"name":"亲子旅拍","type":"11","type_name":"旅拍","status":"1","status_name":"运营中","price":"99.00","ot_price":"199.00","price_deposit":"10.00","description":"描述","attr_label":["亲子"],"sold":"3","material_num":"12","photo_num":"6","video_num":"1","created_at":"2026-06-24","creator_name":"摄影师","photog_list":[{"id":"1","photog_uid":"100","nickname":"小李","avatar":"","completed_order_count":"8"}],"scenic_list":[{"id":"6","name":"大门"}]}}"#.utf8)
|
||||
private static let scenicListResponse = Data(#"{"code":100000,"message":"ok","data":{"total":1,"list":[{"id":"9","name":"测试景区"}]}}"#.utf8)
|
||||
}
|
||||
|
||||
/// 项目 API 测试 URLSession,记录请求并返回固定数据。
|
||||
private final class ProjectRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化单响应测试 Session。
|
||||
init(data: Data) {
|
||||
self.responses = [data]
|
||||
}
|
||||
|
||||
/// 初始化多响应测试 Session。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.count > 1 ? responses.removeFirst() : responses[0]
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func projectQueryItems(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) }
|
||||
})
|
||||
}
|
||||
|
||||
/// 将请求体解析为字典。
|
||||
private func bodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
195
suixinkanTests/Projects/ProjectViewModelTests.swift
Normal file
195
suixinkanTests/Projects/ProjectViewModelTests.swift
Normal file
@ -0,0 +1,195 @@
|
||||
//
|
||||
// ProjectViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 项目 ViewModel 测试,覆盖分页、校验和上传提交顺序。
|
||||
final class ProjectViewModelTests: XCTestCase {
|
||||
/// 测试无景区时清空列表且不请求接口。
|
||||
func testProjectReloadWithoutScenicClearsListAndSkipsRequest() async {
|
||||
let api = FakeProjectService()
|
||||
let viewModel = ProjectManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertEqual(api.projectListCalls.count, 0)
|
||||
}
|
||||
|
||||
/// 测试项目列表首屏和加载更多分页。
|
||||
func testProjectListReloadAndLoadMore() async {
|
||||
let api = FakeProjectService()
|
||||
api.projectPages = [
|
||||
ListPayload(total: 2, list: [.fixture(id: 1, name: "A")]),
|
||||
ListPayload(total: 2, list: [.fixture(id: 2, name: "B")])
|
||||
]
|
||||
let viewModel = ProjectManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 9)
|
||||
await viewModel.loadMore(api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.projectListCalls.map(\.page), [1, 2])
|
||||
XCTAssertEqual(viewModel.items.map(\.id).sorted(), [1, 2])
|
||||
XCTAssertFalse(viewModel.hasMore)
|
||||
}
|
||||
|
||||
/// 测试摄影师项目提交会先上传图片再调用创建接口。
|
||||
func testProjectEditorUploadsImagesBeforeCreate() async {
|
||||
let api = FakeProjectService()
|
||||
let uploader = FakeProjectUploader()
|
||||
let viewModel = ProjectEditorViewModel(mode: .create)
|
||||
viewModel.name = "新项目"
|
||||
viewModel.descriptionText = "描述"
|
||||
viewModel.price = "99"
|
||||
viewModel.otPrice = "199"
|
||||
viewModel.deposit = "10"
|
||||
viewModel.selectedSpotIds = [6]
|
||||
viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil)
|
||||
viewModel.carouselImages = [ProjectLocalImage(data: Data([2]), fileName: "banner.jpg", previewURL: nil)]
|
||||
|
||||
let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(uploader.uploadedFileNames, ["cover.jpg", "banner.jpg"])
|
||||
XCTAssertEqual(api.createdProjects.first?.coverProject, "https://cdn/cover.jpg")
|
||||
XCTAssertEqual(api.createdProjects.first?.coverCarousel, ["https://cdn/banner.jpg"])
|
||||
}
|
||||
|
||||
/// 测试上传失败时不提交业务接口。
|
||||
func testProjectEditorUploadFailureDoesNotCreateProject() async {
|
||||
let api = FakeProjectService()
|
||||
let uploader = FakeProjectUploader()
|
||||
uploader.shouldFail = true
|
||||
let viewModel = ProjectEditorViewModel(mode: .create)
|
||||
viewModel.name = "新项目"
|
||||
viewModel.descriptionText = "描述"
|
||||
viewModel.price = "99"
|
||||
viewModel.selectedSpotIds = [6]
|
||||
viewModel.coverImage = ProjectLocalImage(data: Data([1]), fileName: "cover.jpg", previewURL: nil)
|
||||
|
||||
let success = await viewModel.submit(scenicId: 9, userId: 100, api: api, uploadService: uploader)
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertTrue(api.createdProjects.isEmpty)
|
||||
}
|
||||
|
||||
/// 测试店铺项目缺用户 ID 时不提交。
|
||||
func testStoreProjectEditorRequiresUserId() async {
|
||||
let viewModel = StoreProjectEditorViewModel(mode: .create)
|
||||
let success = await viewModel.submit(userId: 0, api: FakeProjectService(), uploadService: FakeProjectUploader())
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertEqual(viewModel.errorMessage, ProjectEditorError.missingUser.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private extension PhotographerProjectItem {
|
||||
/// 创建测试项目列表项。
|
||||
static func fixture(id: Int, name: String) -> PhotographerProjectItem {
|
||||
PhotographerProjectItem(id: id, name: name, price: "99.00")
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目测试服务替身。
|
||||
@MainActor
|
||||
private final class FakeProjectService: ProjectServing {
|
||||
var projectPages: [ListPayload<PhotographerProjectItem>] = []
|
||||
var projectListCalls: [(scenicId: Int, name: String?, page: Int, pageSize: Int)] = []
|
||||
var createdProjects: [ProjectCreateRequest] = []
|
||||
|
||||
/// 获取摄影师项目列表。
|
||||
func projectList(scenicId: Int, name: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem> {
|
||||
projectListCalls.append((scenicId, name, page, pageSize))
|
||||
return projectPages.isEmpty ? ListPayload(total: 0, list: []) : projectPages.removeFirst()
|
||||
}
|
||||
|
||||
/// 获取摄影师项目详情。
|
||||
func projectDetail(id: Int) async throws -> PhotographerProjectDetailResponse {
|
||||
try Self.detail()
|
||||
}
|
||||
|
||||
/// 创建摄影师项目。
|
||||
func createProject(_ request: ProjectCreateRequest) async throws {
|
||||
createdProjects.append(request)
|
||||
}
|
||||
|
||||
/// 编辑摄影师项目。
|
||||
func editProject(_ request: ProjectCreateRequest) async throws {}
|
||||
|
||||
/// 删除摄影师项目。
|
||||
func deleteProject(id: Int) async throws {}
|
||||
|
||||
/// 获取店铺项目可管理景区。
|
||||
func storeManagerScenicList(userId: String) async throws -> ListPayload<StoreManagerScenicItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 获取店铺项目列表。
|
||||
func storeManagerProjectList(userId: String?, page: Int, pageSize: Int) async throws -> ListPayload<PhotographerProjectItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
/// 获取店铺项目详情。
|
||||
func storeManagerProjectDetail(id: Int) async throws -> PhotographerProjectDetailResponse {
|
||||
try Self.detail()
|
||||
}
|
||||
|
||||
/// 创建店铺多点位项目。
|
||||
func storeManagerCreate(_ request: StoreManagerCreateRequest) async throws {}
|
||||
|
||||
/// 创建店铺押金项目。
|
||||
func storeManagerOfflineCreate(_ request: StoreManagerOfflineCreateRequest) async throws {}
|
||||
|
||||
/// 更新店铺多点位项目。
|
||||
func storeManagerUpdate(_ request: StoreManagerUpdateRequest) async throws {}
|
||||
|
||||
/// 更新店铺押金项目。
|
||||
func storeManagerOfflineUpdate(_ request: StoreManagerOfflineUpdateRequest) async throws {}
|
||||
|
||||
/// 删除店铺项目。
|
||||
func storeManagerDeleteProject(id: Int) async throws {}
|
||||
|
||||
/// 获取全部门店。
|
||||
func storeAll() async throws -> ListPayload<StoreItem> {
|
||||
ListPayload(total: 0, list: [])
|
||||
}
|
||||
|
||||
private static func detail() throws -> PhotographerProjectDetailResponse {
|
||||
let data = Data(#"{"id":1,"name":"项目","type":11,"status":1,"price":"99","description":"描述","material_num":1,"photo_num":1,"video_num":1}"#.utf8)
|
||||
return try JSONDecoder().decode(PhotographerProjectDetailResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// 项目上传测试替身。
|
||||
@MainActor
|
||||
private final class FakeProjectUploader: OSSUploadServing {
|
||||
var uploadedFileNames: [String] = []
|
||||
var shouldFail = false
|
||||
|
||||
/// 上传项目图片。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if shouldFail { throw TestUploadError.failed }
|
||||
uploadedFileNames.append(fileName)
|
||||
return "https://cdn/\(fileName)"
|
||||
}
|
||||
|
||||
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 uploadTaskFile(data: Data, fileName: String, fileType: Int, 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 { "" }
|
||||
}
|
||||
|
||||
/// 测试上传错误。
|
||||
private enum TestUploadError: Error {
|
||||
case failed
|
||||
}
|
||||
@ -211,6 +211,7 @@ private final class MockPunchPointUploader: OSSUploadServing {
|
||||
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 uploadTaskFile(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 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 { "" }
|
||||
}
|
||||
|
||||
@ -291,6 +291,11 @@ private final class OSSUploadServingFake: OSSUploadServing {
|
||||
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传项目图片测试替身。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
|
||||
}
|
||||
|
||||
/// 上传打卡点图片测试替身。
|
||||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
try await upload(data: data, fileName: fileName, scenicId: scenicId, onProgress: onProgress)
|
||||
|
||||
184
suixinkanTests/Schedule/ScheduleTests.swift
Normal file
184
suixinkanTests/Schedule/ScheduleTests.swift
Normal file
@ -0,0 +1,184 @@
|
||||
//
|
||||
// ScheduleTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/24.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 排班 API 和 ViewModel 测试。
|
||||
final class ScheduleTests: XCTestCase {
|
||||
/// 测试排班 API path 和 query/body。
|
||||
func testScheduleAPIUsesExpectedRequests() async throws {
|
||||
let session = ScheduleRecordingURLSession(responses: [
|
||||
Data(#"{"code":100000,"message":"ok","data":["2026-06-24"]}"#.utf8),
|
||||
Data(#"{"code":100000,"message":"ok","data":[{"id":"1","name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}]}"#.utf8),
|
||||
Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8),
|
||||
Data(#"{"code":100000,"message":"ok","data":{}}"#.utf8)
|
||||
])
|
||||
let api = ScheduleAPI(client: APIClient(session: session))
|
||||
|
||||
let days = try await api.monthScheduleDays(scenicId: 9, yearMonth: "2026-06")
|
||||
let list = try await api.dayScheduleList(scenicId: 9, date: "2026-06-24")
|
||||
try await api.addSchedule(AddScheduleRequest(scenicId: "9", name: "拍摄", remark: "备注", startTime: "09:00", endTime: "10:00", scheduleDate: "2026-06-24", orderNumber: "ORD001"))
|
||||
try await api.deleteSchedule(id: 1)
|
||||
|
||||
XCTAssertEqual(days, ["2026-06-24"])
|
||||
XCTAssertEqual(list.first?.id, 1)
|
||||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||||
"/api/yf-handset-app/photog/schedule/list-date",
|
||||
"/api/yf-handset-app/photog/schedule/list",
|
||||
"/api/yf-handset-app/photog/schedule/add",
|
||||
"/api/yf-handset-app/photog/schedule/delete"
|
||||
])
|
||||
XCTAssertEqual(scheduleQueryItems(from: session.requests[0])["date"], "2026-06")
|
||||
XCTAssertEqual(scheduleQueryItems(from: session.requests[1])["date"], "2026-06-24")
|
||||
XCTAssertEqual((try scheduleBodyObject(from: session.requests[2]))["order_number"] as? String, "ORD001")
|
||||
XCTAssertEqual((try scheduleBodyObject(from: session.requests[3]))["id"] as? Int, 1)
|
||||
}
|
||||
|
||||
/// 测试无景区时排班管理清空状态且不请求接口。
|
||||
func testScheduleManagementWithoutScenicClearsState() async {
|
||||
let api = FakeScheduleService()
|
||||
let viewModel = ScheduleManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: nil)
|
||||
|
||||
XCTAssertTrue(viewModel.items.isEmpty)
|
||||
XCTAssertTrue(viewModel.markedDays.isEmpty)
|
||||
XCTAssertEqual(api.monthCalls.count, 0)
|
||||
}
|
||||
|
||||
/// 测试排班管理加载月份和当天列表。
|
||||
func testScheduleManagementReloadLoadsMonthAndDay() async {
|
||||
let api = FakeScheduleService()
|
||||
api.monthDays = ["2026-06-24"]
|
||||
api.dayItems = [.fixture(id: 1)]
|
||||
let viewModel = ScheduleManagementViewModel()
|
||||
|
||||
await viewModel.reload(api: api, scenicId: 9)
|
||||
|
||||
XCTAssertEqual(api.monthCalls.first?.scenicId, 9)
|
||||
XCTAssertEqual(api.dayCalls.first?.scenicId, 9)
|
||||
XCTAssertEqual(viewModel.markedDays, ["2026-06-24"])
|
||||
XCTAssertEqual(viewModel.items.first?.id, 1)
|
||||
}
|
||||
|
||||
/// 测试新增排班优先使用选择的订单号。
|
||||
func testScheduleAddUsesSelectedOrderBeforeManualOrder() async {
|
||||
let api = FakeScheduleService()
|
||||
let viewModel = ScheduleAddViewModel()
|
||||
viewModel.draft.name = "拍摄"
|
||||
viewModel.draft.remark = ""
|
||||
viewModel.draft.manualOrderNumber = "MANUAL"
|
||||
viewModel.draft.selectedOrder = AvailableOrderResponse.fixture(orderNumber: "PICKED")
|
||||
let start = fixedDate(hour: 9)
|
||||
let end = fixedDate(hour: 10)
|
||||
|
||||
let success = await viewModel.submit(api: api, scenicId: 9, startDate: start, endDate: end)
|
||||
|
||||
XCTAssertTrue(success)
|
||||
XCTAssertEqual(api.addedRequests.first?.orderNumber, "PICKED")
|
||||
XCTAssertEqual(api.addedRequests.first?.remark, "拍摄")
|
||||
}
|
||||
|
||||
/// 测试结束时间早于开始时间时禁止提交。
|
||||
func testScheduleAddRejectsInvalidTime() async {
|
||||
let api = FakeScheduleService()
|
||||
let viewModel = ScheduleAddViewModel()
|
||||
viewModel.draft.name = "拍摄"
|
||||
|
||||
let success = await viewModel.submit(api: api, scenicId: 9, startDate: fixedDate(hour: 10), endDate: fixedDate(hour: 9))
|
||||
|
||||
XCTAssertFalse(success)
|
||||
XCTAssertTrue(api.addedRequests.isEmpty)
|
||||
XCTAssertEqual(viewModel.errorMessage, ScheduleValidationError.invalidTime.localizedDescription)
|
||||
}
|
||||
|
||||
private func fixedDate(hour: Int) -> Date {
|
||||
Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: hour, minute: 0)) ?? Date()
|
||||
}
|
||||
}
|
||||
|
||||
private extension ScheduleItem {
|
||||
/// 创建排班测试项。
|
||||
static func fixture(id: Int) -> ScheduleItem {
|
||||
let data = Data(#"{"id":\#(id),"name":"拍摄","start_time":"09:00","end_time":"10:00","schedule_date":"2026-06-24"}"#.utf8)
|
||||
return try! JSONDecoder().decode(ScheduleItem.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
private extension AvailableOrderResponse {
|
||||
/// 创建可关联订单测试项。
|
||||
static func fixture(orderNumber: String) -> AvailableOrderResponse {
|
||||
let data = Data(#"{"project_name":"项目","order_number":"\#(orderNumber)","order_status":"1","order_status_label":"已支付","pay_time":"2026-06-24","user_phone":"13800000000"}"#.utf8)
|
||||
return try! JSONDecoder().decode(AvailableOrderResponse.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排班 API 测试 URLSession。
|
||||
private final class ScheduleRecordingURLSession: URLSessionProtocol {
|
||||
private var responses: [Data]
|
||||
private(set) var requests: [URLRequest] = []
|
||||
|
||||
/// 初始化测试 Session。
|
||||
init(responses: [Data]) {
|
||||
self.responses = responses
|
||||
}
|
||||
|
||||
/// 记录请求并返回响应。
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||||
requests.append(request)
|
||||
let data = responses.removeFirst()
|
||||
return (data, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)
|
||||
}
|
||||
}
|
||||
|
||||
/// 排班服务测试替身。
|
||||
@MainActor
|
||||
private final class FakeScheduleService: ScheduleServing {
|
||||
var monthDays: [String] = []
|
||||
var dayItems: [ScheduleItem] = []
|
||||
var monthCalls: [(scenicId: Int, yearMonth: String)] = []
|
||||
var dayCalls: [(scenicId: Int, date: String)] = []
|
||||
var addedRequests: [AddScheduleRequest] = []
|
||||
|
||||
func monthScheduleDays(scenicId: Int, yearMonth: String) async throws -> [String] {
|
||||
monthCalls.append((scenicId, yearMonth))
|
||||
return monthDays
|
||||
}
|
||||
|
||||
func dayScheduleList(scenicId: Int, date: String) async throws -> [ScheduleItem] {
|
||||
dayCalls.append((scenicId, date))
|
||||
return dayItems
|
||||
}
|
||||
|
||||
func addSchedule(_ request: AddScheduleRequest) async throws {
|
||||
addedRequests.append(request)
|
||||
}
|
||||
|
||||
func deleteSchedule(id: Int) async throws {}
|
||||
|
||||
func availableOrderList(scenicId: Int) async throws -> [AvailableOrderResponse] {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
/// 从请求中提取 query 字典。
|
||||
private func scheduleQueryItems(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) }
|
||||
})
|
||||
}
|
||||
|
||||
/// 将请求体解析为字典。
|
||||
private func scheduleBodyObject(from request: URLRequest) throws -> [String: Any] {
|
||||
let body = try XCTUnwrap(request.httpBody)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||||
}
|
||||
@ -300,6 +300,9 @@ private final class TaskMockUploadService: OSSUploadServing {
|
||||
return taskUploadURLs.isEmpty ? "https://cdn.example.com/task/default.jpg" : taskUploadURLs.removeFirst()
|
||||
}
|
||||
|
||||
/// 模拟上传项目图片。
|
||||
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
/// 模拟上传头像。
|
||||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
|
||||
|
||||
|
||||
@ -47,6 +47,10 @@ final class UploadTests: XCTestCase {
|
||||
OSSUploadPolicy.objectKey(fileName: "bank.jpg", scenicId: 9, moduleType: "bank_card", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"bank_card/20260101/9/123456781234123412341234567890AB_bank.jpg"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "cover.jpg", scenicId: 9, moduleType: "project", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"project/20260101/9/123456781234123412341234567890AB_cover.jpg"
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试 MIME 类型推断和 URL 拼接规则。
|
||||
|
||||
@ -217,6 +217,7 @@ private final class WalletUploadMock: OSSUploadServing {
|
||||
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 uploadTaskFile(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 { "" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user