新增资产模块,包含云存储与媒体库流程

为云端与素材管理页面接入首页路由,将共享云存储模型迁入 Assets 模块,并补充 API/ViewModel 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 16:10:20 +08:00
parent ffb16eca29
commit cac22fcc56
15 changed files with 3431 additions and 75 deletions

View File

@ -0,0 +1,188 @@
//
// AssetsAPITests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/23.
//
import XCTest
@testable import suixinkan
@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)
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(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, listingStatus: 1))
try await api.mediaAlbumDelete(MediaAlbumDeleteRequest(id: 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")
}
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)!
}
/// 资产 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])
}

View File

@ -0,0 +1,364 @@
//
// AssetsViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/23.
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class AssetsViewModelTests: XCTestCase {
/// OSS
func testCloudUploadStopsWhenPermissionDenied() async {
let api = MockAssetsService()
api.uploadPermission = false
api.uploadReason = "空间不足"
let uploader = MockAssetsUploader()
let viewModel = CloudStorageViewModel()
let success = await viewModel.upload(
localFiles: [CloudLocalUploadFile(data: Data([1]), fileName: "a.jpg", fileType: 2)],
scenicId: 9,
api: api,
uploadService: uploader,
transferStore: CloudTransferStore()
)
XCTAssertFalse(success)
XCTAssertEqual(viewModel.errorMessage, "空间不足")
XCTAssertEqual(uploader.uploadedFiles.count, 0)
XCTAssertEqual(api.cloudUploadRequests.count, 0)
}
/// OSS
func testCloudUploadUploadsOSSBeforeCloudRecord() async {
let api = MockAssetsService()
let uploader = MockAssetsUploader()
let transferStore = CloudTransferStore()
let viewModel = CloudStorageViewModel()
let success = await viewModel.upload(
localFiles: [CloudLocalUploadFile(data: Data([1, 2]), fileName: "a.jpg", fileType: 2)],
scenicId: 9,
api: api,
uploadService: uploader,
transferStore: transferStore
)
XCTAssertTrue(success)
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["a.jpg"])
XCTAssertEqual(api.cloudUploadRequests.first?.fileUrl, "https://cdn.example.com/a.jpg")
XCTAssertEqual(transferStore.records.first?.status, .success)
}
///
func testCloudPaginationAndFolderNavigation() async {
let folder = CloudDriveFile(id: 2, parentFolderId: 0, fileUrl: "", name: "子目录", type: 99)
let api = MockAssetsService()
api.cloudPages = [
ListPayload(total: 2, list: [folder]),
ListPayload(total: 2, list: [CloudDriveFile(id: 3, parentFolderId: 0, fileUrl: "https://cdn/a.jpg", name: "a.jpg", type: 2)])
]
let viewModel = CloudStorageViewModel()
await viewModel.reload(api: api)
await viewModel.loadMore(api: api)
await viewModel.enterFolder(folder, api: api)
await viewModel.popToFolder(at: 0, api: api)
XCTAssertEqual(api.cloudListRequests.first?.parentFolderId, 0)
XCTAssertEqual(api.cloudListRequests[1].page, 2)
XCTAssertEqual(api.cloudListRequests[2].parentFolderId, 2)
XCTAssertEqual(viewModel.currentFolderId, 0)
}
///
func testMediaListFilterAndPagination() async {
let api = MockAssetsService()
api.mediaListResponses = [
MediaLibraryListResponse.fixture(total: 2, ids: [1]),
MediaLibraryListResponse.fixture(total: 2, ids: [2])
]
let viewModel = MediaLibraryViewModel(kind: .material)
viewModel.keyword = " 航拍 "
viewModel.selectedAuditFilter = .approved
await viewModel.reload(api: api)
await viewModel.loadMore(api: api)
XCTAssertEqual(api.mediaListRequests.first?.keyword, "航拍")
XCTAssertEqual(api.mediaListRequests.first?.status, 1)
XCTAssertEqual(api.mediaListRequests.last?.page, 2)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
}
///
func testRejectedMediaCannotToggleListing() async {
let api = MockAssetsService()
let viewModel = MediaLibraryViewModel()
let item = MediaLibraryItem.fixture(id: 1, status: 2, listingStatus: 0)
let success = await viewModel.toggleListing(item, api: api)
XCTAssertFalse(success)
XCTAssertEqual(api.mediaOperationRequests.count, 0)
XCTAssertEqual(viewModel.errorMessage, "审核通过后才能上下架")
}
/// OSS URL
func testMediaUploadUploadsFilesBeforeSubmit() async {
let api = MockAssetsService()
let uploader = MockAssetsUploader()
let viewModel = MediaLibraryEditorViewModel()
viewModel.name = "航拍素材"
viewModel.selectedSpotId = 6
viewModel.tagsText = "航拍,夜景"
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
XCTAssertTrue(success)
XCTAssertEqual(uploader.uploadedFiles.map(\.fileName), ["cover.jpg", "a.jpg"])
XCTAssertEqual(api.mediaUploadRequests.first?.coverUrl, "https://cdn.example.com/cover.jpg")
XCTAssertEqual(api.mediaUploadRequests.first?.mediaList.first?.ossUrl, "https://cdn.example.com/a.jpg")
XCTAssertEqual(api.mediaUploadRequests.first?.materialTag, "航拍,夜景")
}
///
func testMediaUploadFailureDoesNotSubmit() async {
let api = MockAssetsService()
let uploader = MockAssetsUploader()
uploader.shouldFail = true
let viewModel = MediaLibraryEditorViewModel()
viewModel.name = "航拍素材"
viewModel.selectedSpotId = 6
viewModel.setCover(MediaLocalUploadFile(data: Data([1]), fileName: "cover.jpg", fileType: 2, width: 100, height: 80))
viewModel.addMediaFiles([MediaLocalUploadFile(data: Data([2]), fileName: "a.jpg", fileType: 2, width: 120, height: 90)])
let success = await viewModel.submit(scenicId: 9, api: api, uploadService: uploader)
XCTAssertFalse(success)
XCTAssertEqual(api.mediaUploadRequests.count, 0)
}
}
///
@MainActor
private final class MockAssetsService: AssetsServing {
struct CloudListRequest: Equatable {
let parentFolderId: Int
let name: String
let type: Int
let orderBy: Int
let page: Int
let pageSize: Int
}
struct MediaListRequest: Equatable {
let keyword: String
let status: Int?
let page: Int
}
var cloudPages: [ListPayload<CloudDriveFile>] = [ListPayload(total: 0, list: [])]
var mediaListResponses: [MediaLibraryListResponse] = [MediaLibraryListResponse.fixture(total: 0, ids: [])]
var uploadPermission = true
var uploadReason = ""
var cloudListRequests: [CloudListRequest] = []
var cloudUploadRequests: [CloudFileUploadRequest] = []
var mediaListRequests: [MediaListRequest] = []
var mediaOperationRequests: [MediaAlbumOperationRequest] = []
var mediaUploadRequests: [MediaAlbumUploadRequest] = []
var mediaEditRequests: [MediaAlbumEditRequest] = []
///
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> ListPayload<CloudDriveFile> {
cloudListRequests.append(CloudListRequest(parentFolderId: parentFolderId, name: name, type: type, orderBy: orderBy, page: page, pageSize: pageSize))
return cloudPages.isEmpty ? ListPayload(total: 0, list: []) : cloudPages.removeFirst()
}
///
func cloudFolderCreate(_ request: CloudFolderCreateRequest) async throws {}
///
func cloudFileUpload(_ request: CloudFileUploadRequest) async throws {
cloudUploadRequests.append(request)
}
///
func cloudFileDelete(_ request: CloudFileDeleteRequest) async throws {}
///
func cloudFileMove(_ request: CloudFileMoveRequest) async throws {}
///
func cloudFolderEdit(_ request: CloudFolderModifyRequest) async throws {}
///
func cloudFileEdit(_ request: CloudFileModifyRequest) async throws {}
///
func checkCloudUploadPermission() async throws -> CheckCloudUploadResponse {
try JSONDecoder().decode(CheckCloudUploadResponse.self, from: #"{"can_upload":\#(uploadPermission ? 1 : 0),"reason":"\#(uploadReason)"}"#.data(using: .utf8)!)
}
///
func mediaAlbumList(kind: MediaLibraryKind, keyword: String, status: Int?, page: Int, pageSize: Int) async throws -> MediaLibraryListResponse {
mediaListRequests.append(MediaListRequest(keyword: keyword, status: status, page: page))
return mediaListResponses.isEmpty ? MediaLibraryListResponse.fixture(total: 0, ids: []) : mediaListResponses.removeFirst()
}
///
func mediaAlbumDetail(id: Int) async throws -> MediaLibraryDetail {
MediaLibraryDetail.fixture(id: id)
}
///
func mediaAlbumOperation(_ request: MediaAlbumOperationRequest) async throws {
mediaOperationRequests.append(request)
}
///
func mediaAlbumDelete(_ request: MediaAlbumDeleteRequest) async throws {}
///
func mediaAlbumAddTag(_ request: MediaAlbumAddTagRequest) async throws {}
///
func mediaAlbumUpload(_ request: MediaAlbumUploadRequest) async throws {
mediaUploadRequests.append(request)
}
///
func mediaAlbumEdit(_ request: MediaAlbumEditRequest) async throws {
mediaEditRequests.append(request)
}
}
/// OSS CDN URL
@MainActor
private final class MockAssetsUploader: OSSUploadServing {
struct UploadedFile: Equatable {
let fileName: String
let fileType: Int
let scenicId: Int
}
var uploadedFiles: [UploadedFile] = []
var shouldFail = false
///
func uploadUserAvatar(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 uploadRealNameImage(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 uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: fileType, scenicId: scenicId, onProgress: onProgress)
}
///
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: fileType, 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)
}
///
func uploadScenicApplicationImage(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 uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
try await upload(fileName: fileName, fileType: 2, scenicId: scenicId, onProgress: onProgress)
}
///
private func upload(fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if shouldFail {
throw APIError.networkFailed("上传失败")
}
uploadedFiles.append(UploadedFile(fileName: fileName, fileType: fileType, scenicId: scenicId))
onProgress(100)
return "https://cdn.example.com/\(fileName)"
}
}
private extension MediaLibraryListResponse {
///
static func fixture(total: Int, ids: [Int]) -> MediaLibraryListResponse {
let list = ids.map { MediaLibraryItem.fixture(id: $0, status: 1, listingStatus: 0) }
let payload = [
"total": total,
"list": list.map { ["id": $0.id, "name": $0.name, "status": $0.status, "listing_status": $0.listingStatus] },
"order": ["total_num": 0, "avg_order_amount": 0, "refund_total": 0, "avg_change": 0]
] as [String: Any]
let data = try! JSONSerialization.data(withJSONObject: payload)
return try! JSONDecoder().decode(MediaLibraryListResponse.self, from: data)
}
}
private extension MediaLibraryItem {
///
static func fixture(id: Int, status: Int, listingStatus: Int) -> MediaLibraryItem {
let payload: [String: Any] = [
"id": id,
"name": "素材\(id)",
"cover_url": "",
"created_at": "",
"status": status,
"download_count": 0,
"likes_count": 0,
"collect_count": 0,
"share_count": 0,
"scenic_spot_name": "",
"project_name": "",
"listing_status": listingStatus
]
let data = try! JSONSerialization.data(withJSONObject: payload)
return try! JSONDecoder().decode(MediaLibraryItem.self, from: data)
}
}
private extension MediaLibraryDetail {
///
static func fixture(id: Int) -> MediaLibraryDetail {
let payload: [String: Any] = [
"id": id,
"name": "素材\(id)",
"cover_url": "",
"description": "",
"status": 1,
"listing_status": 0,
"likes_count": 0,
"download_count": 0,
"collect_count": 0,
"share_count": 0,
"uploader_name": "",
"created_at": "",
"media_list": [],
"scenic_name": "",
"project_name": ""
]
let data = try! JSONSerialization.data(withJSONObject: payload)
return try! JSONDecoder().decode(MediaLibraryDetail.self, from: data)
}
}