// // ProjectAPITests.swift // suixinkanTests // // Created by Codex on 2026/6/24. // import XCTest @testable import suixinkan_ios @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]) }