Add Assets module with cloud storage and media library flows.
Wire home routing for cloud and material management pages, move shared cloud models into Assets, and add API/view model tests. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
364
suixinkanTests/Assets/AssetsViewModelTests.swift
Normal file
364
suixinkanTests/Assets/AssetsViewModelTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user