feat: 同步样片管理功能

This commit is contained in:
2026-07-08 13:06:11 +08:00
parent e88cd4a9a4
commit ccd63380cf
22 changed files with 3791 additions and 3 deletions

View File

@ -0,0 +1,116 @@
//
// SampleManagementAPITests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
@MainActor
/// API
final class SampleManagementAPITests: XCTestCase {
func testListBuildsAndroidQuery() async throws {
let session = MockURLSession(responses: [envelopeJSON(#"{"total":0,"list":[],"order":null}"#)])
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.list(status: 4, keyword: "雪山", page: 2, pageSize: 10)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "GET")
XCTAssertEqual(request.url?.path, "/api/app/media-album/list")
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "status" }?.value, "4")
XCTAssertEqual(query?.first { $0.name == "keyword" }?.value, "雪山")
XCTAssertEqual(query?.first { $0.name == "type" }?.value, "2")
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "2")
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "10")
}
func testDetailAndOperationUseSampleType() async throws {
let detail = envelopeJSON(#"{"id":8,"status":1,"listing_status":1}"#)
let empty = envelopeJSON("{}")
let session = MockURLSession(responses: [detail, empty])
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.detail(id: 8)
try await api.setListingStatus(id: 8, listingStatus: 0)
XCTAssertEqual(session.requests[0].url?.path, "/api/app/media-album/detail")
let detailQuery = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(detailQuery?.first { $0.name == "id" }?.value, "8")
XCTAssertEqual(detailQuery?.first { $0.name == "type" }?.value, "2")
XCTAssertEqual(session.requests[1].httpMethod, "POST")
XCTAssertEqual(session.requests[1].url?.path, "/api/app/media-album/operation")
let operationQuery = URLComponents(url: session.requests[1].url!, resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(operationQuery?.first { $0.name == "id" }?.value, "8")
XCTAssertEqual(operationQuery?.first { $0.name == "listing_status" }?.value, "0")
XCTAssertEqual(operationQuery?.first { $0.name == "type" }?.value, "2")
}
func testUploadEncodesAndroidBodyKeys() async throws {
let session = MockURLSession(responses: [envelopeJSON("{}")])
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
let request = SampleUploadMaterialRequest(
name: "样片A",
mediaType: .image,
mediaList: [
SampleUploadMediaItem(
originalName: "a.jpg",
ossUrl: "https://cdn/a.jpg",
size: 1024,
fileWidthSize: SampleUploadFileSize(width: 1920, height: 1080)
),
],
coverUrl: "https://cdn/c.jpg",
coverSize: "512",
scenicSpotId: 9,
projectId: 7
)
try await api.upload(request)
let urlRequest = try XCTUnwrap(session.requests.first)
XCTAssertEqual(urlRequest.httpMethod, "POST")
XCTAssertEqual(urlRequest.url?.path, "/api/app/media-album/upload")
let body = try JSONSerialization.jsonObject(with: try XCTUnwrap(urlRequest.httpBody)) as? [String: Any]
XCTAssertEqual(body?["name"] as? String, "样片A")
XCTAssertEqual(body?["type"] as? String, "2")
XCTAssertEqual(body?["media_type"] as? String, "1")
XCTAssertEqual(body?["cover_url"] as? String, "https://cdn/c.jpg")
XCTAssertEqual(body?["cover_size"] as? String, "512")
XCTAssertEqual(body?["scenic_spot_id"] as? Int, 9)
XCTAssertEqual(body?["material_tag"] as? String, "")
XCTAssertEqual(body?["project_id"] as? Int, 7)
let media = (body?["media_list"] as? [[String: Any]])?.first
XCTAssertEqual(media?["original_name"] as? String, "a.jpg")
XCTAssertEqual(media?["oss_url"] as? String, "https://cdn/a.jpg")
let fileSize = media?["file_width_size"] as? [String: Any]
XCTAssertEqual(fileSize?["width"] as? Int, 1920)
XCTAssertEqual(fileSize?["height"] as? Int, 1080)
}
func testProjectAndScenicSpotPaths() async throws {
let spots = envelopeJSON(#"{"total":1,"list":[]}"#)
let projects = envelopeJSON(#"{"total":1,"list":[]}"#)
let session = MockURLSession(responses: [spots, projects])
let api = SampleManagementAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.scenicSpotListAll(scenicId: "12")
_ = try await api.projectList(scenicId: "12", name: nil, page: 3, pageSize: 10)
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/scenic-spot/list-all")
XCTAssertEqual(URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems?.first { $0.name == "scenic_id" }?.value, "12")
XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/project/list")
let query = URLComponents(url: session.requests[1].url!, resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(query?.first { $0.name == "scenic_id" }?.value, "12")
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "3")
XCTAssertEqual(query?.first { $0.name == "page_size" }?.value, "10")
}
private func envelopeJSON(_ dataJSON: String) -> Data {
"""
{"code":100000,"msg":"success","data":\(dataJSON)}
""".data(using: .utf8)!
}
}

View File

@ -0,0 +1,197 @@
//
// SampleManagementViewModelTests.swift
// suixinkanTests
//
import Foundation
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class SampleManagementViewModelTests: XCTestCase {
func testListLoadsRefreshesAndMapsFilterStatus() async {
let api = MockSampleAPI()
api.listResponses = [
SampleMaterialListResponse(total: 12, list: (0 ..< 10).map { makeItem(id: $0) }),
SampleMaterialListResponse(total: 12, list: [makeItem(id: 10), makeItem(id: 11)]),
SampleMaterialListResponse(total: 1, list: [makeItem(id: 99)]),
]
let viewModel = SampleListViewModel()
await viewModel.loadInitial(api: api)
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api)
await viewModel.selectFilter(.pending, api: api)
XCTAssertEqual(api.listCalls.map(\.status), [4, 4, 0])
XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1])
XCTAssertEqual(viewModel.items.map(\.id), [99])
}
func testRejectedSampleCannotToggleListing() async {
let api = MockSampleAPI()
api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 1, status: 0, listingStatus: 0)])]
let viewModel = SampleListViewModel()
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadInitial(api: api)
await viewModel.toggleListing(sampleId: 1, checked: true, api: api)
XCTAssertEqual(messages.last, "审核通过才可以操作上下架")
XCTAssertTrue(api.operationCalls.isEmpty)
}
func testApprovedSampleToggleUpdatesLocalItem() async {
let api = MockSampleAPI()
api.listResponses = [SampleMaterialListResponse(total: 1, list: [makeItem(id: 2, status: 1, listingStatus: 0)])]
let viewModel = SampleListViewModel()
await viewModel.loadInitial(api: api)
await viewModel.toggleListing(sampleId: 2, checked: true, api: api)
XCTAssertEqual(api.operationCalls.first?.id, 2)
XCTAssertEqual(api.operationCalls.first?.listingStatus, 1)
XCTAssertEqual(viewModel.items.first?.listingStatus, 1)
}
func testUploadValidationMessages() async {
let api = MockSampleAPI()
let viewModel = UploadSampleViewModel(
scenicListProvider: { [ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)] },
currentScenicIdProvider: { 3 }
)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.uploadSample(api: api)
viewModel.updateAlbumName("样片")
await viewModel.uploadSample(api: api)
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg", ossUrl: "")], uploader: MockSampleUploader(urls: ["https://cdn/a.jpg"]))
await viewModel.uploadSample(api: api)
await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg", ossUrl: ""), uploader: MockSampleUploader(urls: ["https://cdn/c.jpg"]))
await viewModel.uploadSample(api: api)
XCTAssertEqual(messages.prefix(4), [
"请输入相册名称",
"请至少上传一个素材",
"请上传封面图片",
"请选择关联景区",
])
XCTAssertNil(api.lastUploadRequest)
}
func testUploadSuccessBuildsSampleRequest() async {
let scenic = ScenicInfo(id: 3, name: "景区", status: 1, coverImg: nil)
let api = MockSampleAPI()
api.spotResponse = ScenicSpotListResponse(list: [ScenicSpotItem(id: 9, name: "打卡点")], total: 1)
let viewModel = UploadSampleViewModel(
scenicListProvider: { [scenic] },
currentScenicIdProvider: { 3 }
)
let uploader = MockSampleUploader(urls: ["https://cdn/a.jpg", "https://cdn/c.jpg"])
var didSucceed = false
viewModel.onUploadSuccess = { didSucceed = true }
viewModel.updateAlbumName("样片")
await viewModel.selectScenic(scenic, api: api)
viewModel.selectScenicSpot(ScenicSpotItem(id: 9, name: "打卡点"))
viewModel.selectProject(SampleProjectItem(id: 7, name: "项目"))
await viewModel.addLocalMedia([makeUploadItem(fileName: "a.jpg")], uploader: uploader)
await viewModel.setCoverImage(makeUploadItem(fileName: "c.jpg"), uploader: uploader)
await viewModel.uploadSample(api: api)
XCTAssertTrue(didSucceed)
XCTAssertEqual(api.lastUploadRequest?.name, "样片")
XCTAssertEqual(api.lastUploadRequest?.type, "2")
XCTAssertEqual(api.lastUploadRequest?.mediaType, "1")
XCTAssertEqual(api.lastUploadRequest?.mediaList.first?.ossUrl, "https://cdn/a.jpg")
XCTAssertEqual(api.lastUploadRequest?.coverUrl, "https://cdn/c.jpg")
XCTAssertEqual(api.lastUploadRequest?.scenicSpotId, 9)
XCTAssertEqual(api.lastUploadRequest?.projectId, 7)
}
}
@MainActor
private final class MockSampleAPI: SampleManagementServing {
var listResponses: [SampleMaterialListResponse] = []
var listCalls: [(status: Int, keyword: String?, page: Int, pageSize: Int)] = []
var operationCalls: [(id: Int, listingStatus: Int)] = []
var lastUploadRequest: SampleUploadMaterialRequest?
var spotResponse = ScenicSpotListResponse(list: [], total: 0)
var projectResponse = SampleProjectListResponse(list: [], total: 0)
func list(status: Int, keyword: String?, page: Int, pageSize: Int) async throws -> SampleMaterialListResponse {
listCalls.append((status, keyword, page, pageSize))
guard !listResponses.isEmpty else { return SampleMaterialListResponse() }
return listResponses.removeFirst()
}
func detail(id: Int) async throws -> SampleDetail {
throw APIError.emptyData
}
func setListingStatus(id: Int, listingStatus: Int) async throws {
operationCalls.append((id, listingStatus))
}
func upload(_ request: SampleUploadMaterialRequest) async throws {
lastUploadRequest = request
}
func scenicSpotListAll(scenicId: String) async throws -> ScenicSpotListResponse {
spotResponse
}
func projectList(scenicId: String, name: String?, page: Int, pageSize: Int) async throws -> SampleProjectListResponse {
projectResponse
}
}
@MainActor
private final class MockSampleUploader: SampleOSSUploading {
private var urls: [String]
init(urls: [String]) {
self.urls = urls
}
func uploadSampleFile(
data: Data,
fileName: String,
fileType: Int,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
onProgress(100)
return urls.isEmpty ? "https://cdn/default.jpg" : urls.removeFirst()
}
}
private func makeItem(id: Int, status: Int = 1, listingStatus: Int = 0) -> SampleMaterialItem {
SampleMaterialItem(
id: id,
name: "样片\(id)",
coverUrl: "",
createdAt: "",
status: status,
downloadCount: 0,
likesCount: 0,
collectCount: 0,
shareCount: 0,
scenicSpotName: "打卡点",
projectName: "项目",
listingStatus: listingStatus
)
}
private func makeUploadItem(fileName: String, ossUrl: String = "") -> SampleUploadedMediaItem {
SampleUploadedMediaItem(
data: Data(repeating: 1, count: 10),
fileName: fileName,
size: 10,
width: 1920,
height: 1080,
ossUrl: ossUrl
)
}