// // AssetsAPITests.swift // suixinkanTests // // Created by Codex on 2026/6/23. // import XCTest @testable import suixinkan_ios @MainActor /// 资产 API 测试,覆盖云盘和素材接口请求以及宽松模型解码。 final class AssetsAPITests: XCTestCase { /// 测试云盘列表接口使用正确 path 和 query。 func testCloudFileListUsesExpectedPathAndQuery() async throws { let session = AssetsRecordingURLSession(data: Self.cloudListResponse) let api = AssetsAPI(client: APIClient(session: session)) let payload = try await api.cloudFileList(parentFolderId: 9, name: " 照片 ", type: 2, orderBy: 1, page: 0, pageSize: 0) let request = try XCTUnwrap(session.requests.first) XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/cloud-driver/list") let query = assetsQueryItems(from: request) XCTAssertEqual(query["parent_folder_id"], "9") XCTAssertEqual(query["name"], " 照片 ") XCTAssertEqual(query["type"], "2") XCTAssertEqual(query["order_by"], "1") XCTAssertEqual(query["page"], "1") XCTAssertEqual(query["page_size"], "1") XCTAssertEqual(payload.total, 2) XCTAssertEqual(payload.list.dropFirst().first?.fileSize, 2048) } /// 测试云盘写操作使用正确 path 和 body。 func testCloudMutationRequestsUseExpectedBodies() async throws { let session = AssetsRecordingURLSession(data: Self.emptyResponse) let api = AssetsAPI(client: APIClient(session: session)) try await api.cloudFolderCreate(CloudFolderCreateRequest(parentFolderId: 1, name: "新文件夹")) try await api.cloudFileUpload(CloudFileUploadRequest(parentFolderId: 1, fileUrl: "https://cdn/a.jpg", fileName: "a.jpg")) try await api.cloudFileDelete(CloudFileDeleteRequest(list: [CloudFileActionItem(id: 2, type: 99)])) try await api.cloudFileMove(CloudFileMoveRequest(targetFolderId: 3, list: [CloudFileActionItem(id: 2, type: 2)])) try await api.cloudFolderEdit(CloudFolderModifyRequest(id: 2, name: "改名")) try await api.cloudFileEdit(CloudFileModifyRequest(id: 4, fileName: "b.jpg")) XCTAssertEqual(session.requests.map { $0.url?.path }, [ "/api/yf-handset-app/photog/cloud-driver/folder-create", "/api/yf-handset-app/photog/cloud-driver/file-upload", "/api/yf-handset-app/photog/cloud-driver/delete", "/api/yf-handset-app/photog/cloud-driver/move", "/api/yf-handset-app/photog/cloud-driver/folder-edit", "/api/yf-handset-app/photog/cloud-driver/file-edit" ]) let uploadBody = try bodyObject(from: session.requests[1]) XCTAssertEqual(uploadBody["parent_folder_id"] as? Int, 1) XCTAssertEqual(uploadBody["file_url"] as? String, "https://cdn/a.jpg") let moveBody = try bodyObject(from: session.requests[3]) XCTAssertEqual(moveBody["target_folder_id"] as? Int, 3) } /// 测试云盘上传权限接口兼容字符串布尔值。 func testCloudUploadPermissionDecodesLossyFields() async throws { let session = AssetsRecordingURLSession(data: Self.permissionResponse) let api = AssetsAPI(client: APIClient(session: session)) let permission = try await api.checkCloudUploadPermission() XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/cloud-driver/check-upload-permission") XCTAssertFalse(permission.canUpload) XCTAssertEqual(permission.reason, "空间不足") } /// 测试素材列表和详情接口使用正确 path 并宽松解码字段。 func testMediaListAndDetailDecodeLossyFields() async throws { let session = AssetsRecordingURLSession(responses: [Self.mediaListResponse, Self.mediaDetailResponse]) let api = AssetsAPI(client: APIClient(session: session)) let list = try await api.mediaAlbumList(kind: .material, keyword: "航拍", status: 1, page: 0, pageSize: 0) let detail = try await api.mediaAlbumDetail(id: 7, kind: .material) XCTAssertEqual(session.requests.first?.url?.path, "/api/app/media-album/list") let query = assetsQueryItems(from: try XCTUnwrap(session.requests.first)) XCTAssertEqual(query["type"], "1") XCTAssertEqual(query["status"], "1") XCTAssertEqual(query["page"], "1") XCTAssertEqual(query["page_size"], "1") XCTAssertEqual(list.order?.totalNum, 12) XCTAssertEqual(list.list.first?.likesCount, 3) XCTAssertEqual(session.requests.last?.url?.path, "/api/app/media-album/detail") XCTAssertEqual(assetsQueryItems(from: try XCTUnwrap(session.requests.last))["type"], "1") XCTAssertEqual(detail.mediaList.first?.size, 4096) XCTAssertTrue(detail.mediaList.first?.isVideo == true) } /// 测试素材写操作 path 和请求体。 func testMediaMutationRequestsUseExpectedBodies() async throws { let session = AssetsRecordingURLSession(data: Self.emptyResponse) let api = AssetsAPI(client: APIClient(session: session)) let upload = MediaAlbumUploadRequest( name: "素材", type: 1, mediaType: 2, mediaList: [MediaAlbumUploadItem(originalName: "a.jpg", ossUrl: "https://cdn/a.jpg", size: 10, fileWidthSize: MediaAlbumFileSize(width: 100, height: 80))], coverUrl: "https://cdn/c.jpg", coverSize: MediaAlbumFileSize(width: 100, height: 80), scenicSpotId: 6, description: "描述", materialTag: "航拍", projectId: 0 ) try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 1, type: 1, listingStatus: 1)) try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 1, type: 1)) try await api.mediaAlbumAddTag(MediaAlbumAddTagRequest(name: "航拍")) try await api.mediaAlbumUpload(upload) try await api.mediaAlbumEdit(MediaAlbumEditRequest(id: 2, name: "素材", type: 1, mediaType: 2, mediaList: upload.mediaList, coverUrl: upload.coverUrl, coverSize: upload.coverSize, scenicSpotId: 6, description: "描述", materialTag: "航拍")) XCTAssertEqual(session.requests.map { $0.url?.path }, [ "/api/app/media-album/operation", "/api/app/media-album/delete", "/api/app/media-album/add-tag", "/api/app/media-album/upload", "/api/app/media-album/edit" ]) let body = try bodyObject(from: session.requests[3]) XCTAssertEqual(body["scenic_spot_id"] as? Int, 6) XCTAssertEqual((body["media_list"] as? [[String: Any]])?.first?["oss_url"] as? String, "https://cdn/a.jpg") } /// 测试样片请求携带 type=2,项目列表接口和项目字段宽松解码正确。 func testSampleRequestsAndProjectListUseExpectedTypeAndQuery() async throws { let session = AssetsRecordingURLSession(responses: [ Self.mediaListResponse, Self.mediaDetailResponse, Self.emptyResponse, Self.emptyResponse, Self.projectListResponse, Self.emptyResponse ]) let api = AssetsAPI(client: APIClient(session: session)) let upload = MediaAlbumUploadRequest( name: "亲子样片", type: 2, mediaType: 2, mediaList: [MediaAlbumUploadItem(originalName: "sample.jpg", ossUrl: "https://cdn/sample.jpg", size: 10, fileWidthSize: MediaAlbumFileSize(width: 100, height: 80))], coverUrl: "https://cdn/cover.jpg", coverSize: MediaAlbumFileSize(width: 100, height: 80), scenicSpotId: 6, description: "描述", materialTag: "", projectId: 88 ) _ = try await api.mediaAlbumList(kind: .sample, keyword: "样片", status: nil, page: 1, pageSize: 10) _ = try await api.mediaAlbumDetail(id: 7, kind: .sample) try await api.mediaAlbumOperation(MediaAlbumOperationRequest(id: 7, type: 2, listingStatus: 1)) try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 7, type: 2)) let projects = try await api.projectList(scenicId: 9, name: "亲子", page: 0, pageSize: 0) try await api.mediaAlbumUpload(upload) XCTAssertEqual(assetsQueryItems(from: session.requests[0])["type"], "2") XCTAssertEqual(assetsQueryItems(from: session.requests[1])["type"], "2") XCTAssertEqual((try bodyObject(from: session.requests[2]))["type"] as? Int, 2) XCTAssertEqual((try bodyObject(from: session.requests[3]))["type"] as? Int, 2) XCTAssertEqual(session.requests[4].url?.path, "/api/yf-handset-app/photog/project/list") let projectQuery = assetsQueryItems(from: session.requests[4]) XCTAssertEqual(projectQuery["scenic_id"], "9") XCTAssertEqual(projectQuery["name"], "亲子") XCTAssertEqual(projectQuery["page"], "1") XCTAssertEqual(projectQuery["page_size"], "1") XCTAssertEqual(projects.list.first?.id, 88) XCTAssertEqual(projects.list.first?.name, "亲子旅拍") XCTAssertEqual((try bodyObject(from: session.requests[5]))["project_id"] as? Int, 88) } /// 测试相册接口使用旧工程路径、query 和请求体。 func testAlbumRequestsUseExpectedPathQueryAndBodies() async throws { let session = AssetsRecordingURLSession(responses: [ Self.albumFolderListResponse, Self.albumFolderInfoResponse, Self.albumFileListResponse, Self.emptyResponse, Self.emptyResponse, Self.emptyResponse, Self.emptyResponse ]) let api = AssetsAPI(client: APIClient(session: session)) let folders = try await api.albumFolderList( scenicId: 9, page: 0, pageSize: 0, name: "旅拍", startTime: "2026-06-01", endTime: "2026-06-24" ) let info = try await api.albumFolderInfo(id: 3) let files = try await api.albumFileList(scenicId: 9, folderId: 3, fileType: 2, page: 0, pageSize: 0) try await api.addAlbumFolder(scenicId: 9, name: "新相册", remark: "备注") try await api.editAlbumFolder(folderId: 3, coverFileId: 11, name: "改名", remark: nil) try await api.deleteAlbumFiles(folderId: 3, idList: [11, 12]) try await api.albumFileUploadURL(scenicId: 9, fileURL: "https://cdn/a.jpg", folderId: 3) XCTAssertEqual(session.requests.map { $0.url?.path }, [ "/api/yf-handset-app/photog/album/folder-list", "/api/yf-handset-app/photog/album/folder-info", "/api/yf-handset-app/photog/album/file-list", "/api/yf-handset-app/photog/album/folder-add", "/api/yf-handset-app/photog/album/folder-edit", "/api/yf-handset-app/photog/album/file-delete", "/api/yf-handset-app/photog/album/file-upload-url" ]) let folderQuery = assetsQueryItems(from: session.requests[0]) XCTAssertEqual(folderQuery["scenic_id"], "9") XCTAssertEqual(folderQuery["page"], "1") XCTAssertEqual(folderQuery["page_size"], "1") XCTAssertEqual(folderQuery["cloud_folder_type"], "1") XCTAssertEqual(folderQuery["name"], "旅拍") XCTAssertEqual(folderQuery["start_time"], "2026-06-01") XCTAssertEqual(folderQuery["end_time"], "2026-06-24") XCTAssertEqual(folders.list.first?.countImage, 4) XCTAssertEqual(info.coverFile?.fileSize, 2048) XCTAssertTrue(files.list.first?.isImage == true) let addBody = try bodyObject(from: session.requests[3]) XCTAssertEqual(addBody["scenic_id"] as? String, "9") XCTAssertEqual(addBody["cloud_folder_type"] as? Int, 1) let editBody = try bodyObject(from: session.requests[4]) XCTAssertEqual(editBody["cover_file_id"] as? Int, 11) let deleteBody = try bodyObject(from: session.requests[5]) XCTAssertEqual(deleteBody["folder_id"] as? Int, 3) XCTAssertEqual(deleteBody["id_list"] as? [Int], [11, 12]) let uploadQuery = assetsQueryItems(from: session.requests[6]) XCTAssertEqual(uploadQuery["file_url"], "https://cdn/a.jpg") } fileprivate static let emptyResponse = #"{"code":100000,"msg":"success","data":{}}"#.data(using: .utf8)! fileprivate static let permissionResponse = #"{"code":100000,"msg":"success","data":{"can_upload":"0","reason":"空间不足"}}"#.data(using: .utf8)! fileprivate static let cloudListResponse = """ {"code":100000,"msg":"success","data":{"total":"2","list":[ {"id":"1","parent_folder_id":0,"file_url":"","cover_url":"","updated_at":"2026","name":"文件夹","created_at":"2026","child_num":"3","type":"99","file_size":"0"}, {"id":2,"parent_folder_id":"0","file_url":"https://cdn/a.jpg","cover_url":"","updated_at":"2026","name":"a.jpg","created_at":"2026","child_num":0,"type":2,"file_size":"2048"} ]}} """.data(using: .utf8)! fileprivate static let mediaListResponse = """ {"code":100000,"msg":"success","data":{"total":"1","order":{"total_num":"12","avg_order_amount":"8.5","refund_total":1,"avg_change":"0.2"},"list":[ {"id":"7","name":"航拍","cover_url":"https://cdn/c.jpg","created_at":"2026","status":"1","download_count":"2","likes_count":"3","collect_count":"4","share_count":"5","scenic_spot_name":"东门","project_name":"项目","listing_status":"1"} ]}} """.data(using: .utf8)! fileprivate static let mediaDetailResponse = """ {"code":100000,"msg":"success","data":{"id":"7","name":"航拍","cover_url":"https://cdn/c.jpg","description":"描述","status":"1","listing_status":"1","likes_count":"3","download_count":"2","collect_count":"4","share_count":"5","uploader_name":"摄影师","created_at":"2026","scenic_name":"景区","project_name":"项目","media_list":[ {"id":"9","original_name":"v.mp4","oss_url":"https://cdn/v.mp4","thumbnail_url":"","type":"1","size":"4096","show_url":"https://cdn/v.mp4"} ]}} """.data(using: .utf8)! fileprivate static let projectListResponse = """ {"code":100000,"msg":"success","data":{"total":"1","list":[ {"id":"88","type":"19","type_name":"多点旅拍","status":"1","status_name":"上架","name":"亲子旅拍","cover_project":"https://cdn/p.jpg","cover_video":"","price":"299","ot_price":"399","price_deposit":"99"} ]}} """.data(using: .utf8)! fileprivate static let albumFolderListResponse = """ {"code":100000,"msg":"success","data":{"total":"1","list":[ {"id":"3","name":"旅拍","cover_file_id":"11","count_video":"2","count_image":"4","cover":{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""},"created_at":"2026","remark":"备注"} ]}} """.data(using: .utf8)! fileprivate static let albumFolderInfoResponse = """ {"code":100000,"msg":"success","data":{"id":"3","name":"旅拍","cover_file_id":"11","count_video":"2","count_image":"4","cover":{"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""},"created_at":"2026","remark":"备注"}} """.data(using: .utf8)! fileprivate static let albumFileListResponse = """ {"code":100000,"msg":"success","data":{"total":"1","list":[ {"id":"11","file_name":"a.jpg","file_type":"2","file_url":"https://cdn/a.jpg","cover_url":"","file_size":"2048","file_size_human":"2KB","remark":""} ]}} """.data(using: .utf8)! } /// 资产 API 测试用 URLSession,记录请求并返回固定响应。 private final class AssetsRecordingURLSession: URLSessionProtocol { private var responses: [Data] private(set) var requests: [URLRequest] = [] /// 初始化单一响应。 init(data: Data) { responses = [data] } /// 初始化顺序响应。 init(responses: [Data]) { self.responses = responses } /// 记录请求并返回下一份响应。 func data(for request: URLRequest) async throws -> (Data, URLResponse) { requests.append(request) let data = responses.isEmpty ? AssetsAPITests.emptyResponse : responses.removeFirst() let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! return (data, response) } } /// 从请求中提取 query 字典。 private func assetsQueryItems(from request: URLRequest) -> [String: String] { guard let url = request.url, let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return [:] } return Dictionary(uniqueKeysWithValues: (components.queryItems ?? []).map { ($0.name, $0.value ?? "") }) } /// 将请求体解析成字典。 private func bodyObject(from request: URLRequest) throws -> [String: Any] { let data = try XCTUnwrap(request.httpBody) return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) }