新增项目、排期与邀请模块,并接入首页路由

将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-24 15:57:15 +08:00
parent abcac9bfdf
commit 311a70d610
40 changed files with 4496 additions and 108 deletions

View 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)
}
/// pathquery 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])
}