Files
suixinkan_ios_new/suixinkanTests/PunchPoint/PunchPointAPITests.swift
汉秋 311a70d610 新增项目、排期与邀请模块,并接入首页路由
将项目管理、排期管理与摄影师邀请流程从占位页迁移为完整实现,包含项目图片 OSS 上传与共享业务模型。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 15:57:15 +08:00

234 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// PunchPointAPITests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/24.
//
import XCTest
@testable import suixinkan
@MainActor
/// API
final class PunchPointAPITests: XCTestCase {
/// 使 path query
func testPunchPointListUsesExpectedPathAndQuery() async throws {
let session = PunchPointRecordingSession(data: Self.listResponse)
let api = PunchPointAPI(client: APIClient(session: session))
let payload = try await api.punchPointList(scenicId: 88, status: -1, page: 0, pageSize: 0)
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/scenic-spot/list")
let query = queryItems(from: request)
XCTAssertEqual(query["scenic_area_id"], "88")
XCTAssertEqual(query["status"], "0")
XCTAssertEqual(query["page"], "1")
XCTAssertEqual(query["page_size"], "1")
XCTAssertEqual(payload.total, 1)
XCTAssertEqual(payload.list.first?.id, 7)
XCTAssertEqual(payload.list.first?.region?.lat, 30.1)
}
///
func testPunchPointMutationRequestsUseExpectedBodies() async throws {
let session = PunchPointRecordingSession(responses: [Self.infoResponse, Self.emptyResponse, Self.emptyResponse, Self.emptyResponse])
let api = PunchPointAPI(client: APIClient(session: session))
let region = PunchPointRegion(lat: 30.1, lot: 120.2, address: "入口", scenicSpotStr: "入口点")
_ = try await api.punchPointInfo(id: 7)
try await api.addPunchPoint(AddPunchPointRequest(scenicAreaId: "88", name: "入口", description: "描述", region: region, scenicSpotStr: "入口点", guideImages: ["https://cdn/a.jpg"]))
try await api.editPunchPoint(EditPunchPointRequest(id: 7, scenicAreaId: "88", name: "入口2", description: "描述2", region: region, scenicSpotStr: "入口点", guideImages: []))
try await api.deletePunchPoint(id: 7)
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/yf-handset-app/photog/scenic-spot/info",
"/api/yf-handset-app/photog/scenic-spot/add",
"/api/yf-handset-app/photog/scenic-spot/edit",
"/api/yf-handset-app/photog/scenic-spot/delete"
])
XCTAssertEqual(queryItems(from: session.requests[0])["id"], "7")
let addBody = try bodyObject(from: session.requests[1])
XCTAssertEqual(addBody["scenic_area_id"] as? String, "88")
XCTAssertEqual(addBody["name"] as? String, "入口")
XCTAssertEqual((addBody["guide_imgs"] as? [String])?.first, "https://cdn/a.jpg")
let deleteBody = try bodyObject(from: session.requests[3])
XCTAssertEqual(deleteBody["id"] as? Int, 7)
}
private static let emptyResponse = Data(#"{"code":100000,"msg":"ok","data":{}}"#.utf8)
private static let listResponse = Data(#"{"code":100000,"msg":"ok","data":{"total":"1","list":[{"id":"7","scenic_area_id":"88","name":100,"status":"1","status_label":"","description":"","region":{"lat":"30.1","lot":120.2,"address":"","scenic_spot_str":""},"scenic_spot_str":"","guide_imgs":["https://cdn/a.jpg"],"mp_qrcode":"https://cdn/qr.png","created_at":"2026-06-24"}]}}"#.utf8)
private static let infoResponse = Data(#"{"code":100000,"msg":"ok","data":{"id":7,"name":"","status":1,"region":{"lat":30.1,"lot":"120.2","address":""},"guide_imgs":[]}}"#.utf8)
}
@MainActor
/// ViewModel
final class PunchPointViewModelTests: XCTestCase {
///
func testListClearsWhenScenicMissing() async {
let api = MockPunchPointService()
let viewModel = PunchPointListViewModel()
viewModel.items = [PunchPointItem(id: 1, name: "旧数据")]
await viewModel.reload(scenicId: nil, api: api)
XCTAssertTrue(viewModel.items.isEmpty)
XCTAssertEqual(api.listRequests.count, 0)
}
///
func testListPaginationStopsAtLastPage() async {
let api = MockPunchPointService()
api.pages = [
ListPayload(total: 2, list: [PunchPointItem(id: 1, name: "A")]),
ListPayload(total: 2, list: [PunchPointItem(id: 2, name: "B")])
]
let viewModel = PunchPointListViewModel()
await viewModel.reload(scenicId: 88, api: api)
await viewModel.loadMore(scenicId: 88, api: api)
await viewModel.loadMore(scenicId: 88, api: api)
XCTAssertEqual(viewModel.items.map(\.id), [1, 2])
XCTAssertEqual(api.listRequests.map(\.page), [1, 2])
}
///
func testDeleteRefreshesList() async {
let api = MockPunchPointService()
let viewModel = PunchPointListViewModel()
let success = await viewModel.delete(PunchPointItem(id: 9, name: "A"), scenicId: 88, api: api)
XCTAssertTrue(success)
XCTAssertEqual(api.deletedIds, [9])
XCTAssertEqual(api.listRequests.count, 1)
}
/// OSS URL
func testEditorUploadsImagesBeforeSubmit() async {
let api = MockPunchPointService()
let uploader = MockPunchPointUploader()
let viewModel = PunchPointEditorViewModel()
viewModel.name = "入口"
viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口")
viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")])
let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader)
XCTAssertTrue(success)
XCTAssertEqual(uploader.uploadedFileNames, ["a.jpg"])
XCTAssertEqual(api.addRequests.first?.guideImages.first, "https://cdn.example.com/a.jpg")
}
///
func testEditorUploadFailureDoesNotSubmit() async {
let api = MockPunchPointService()
let uploader = MockPunchPointUploader()
uploader.shouldFail = true
let viewModel = PunchPointEditorViewModel()
viewModel.name = "入口"
viewModel.applyLocation(latitude: 30.1, longitude: 120.2, address: "入口")
viewModel.addLocalImages([PunchPointLocalImage(data: Data([1]), fileName: "a.jpg")])
let success = await viewModel.submit(scenicId: 88, api: api, uploadService: uploader)
XCTAssertFalse(success)
XCTAssertEqual(api.addRequests.count, 0)
}
}
/// API URLSession
private final class PunchPointRecordingSession: URLSessionProtocol {
private var responses: [Data]
private(set) var requests: [URLRequest] = []
/// Session
init(data: Data) {
responses = [data]
}
/// Session
init(responses: [Data]) {
self.responses = responses
}
///
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
requests.append(request)
let data = responses.isEmpty ? Data(#"{"code":100000,"data":{}}"#.utf8) : responses.removeFirst()
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (data, response)
}
}
@MainActor
///
private final class MockPunchPointService: PunchPointServing {
var pages: [ListPayload<PunchPointItem>] = [ListPayload(total: 0, list: [])]
var listRequests: [(scenicId: Int, status: Int, page: Int, pageSize: Int)] = []
var addRequests: [AddPunchPointRequest] = []
var editRequests: [EditPunchPointRequest] = []
var deletedIds: [Int] = []
func punchPointList(scenicId: Int, status: Int, page: Int, pageSize: Int) async throws -> ListPayload<PunchPointItem> {
listRequests.append((scenicId, status, page, pageSize))
return pages.isEmpty ? ListPayload(total: 0, list: []) : pages.removeFirst()
}
func punchPointInfo(id: Int) async throws -> PunchPointItem {
PunchPointItem(id: id, name: "详情")
}
func addPunchPoint(_ request: AddPunchPointRequest) async throws {
addRequests.append(request)
}
func editPunchPoint(_ request: EditPunchPointRequest) async throws {
editRequests.append(request)
}
func deletePunchPoint(id: Int) async throws {
deletedIds.append(id)
}
}
@MainActor
/// OSS
private final class MockPunchPointUploader: OSSUploadServing {
var shouldFail = false
var uploadedFileNames: [String] = []
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
if shouldFail { throw NSError(domain: "upload", code: 1) }
uploadedFileNames.append(fileName)
onProgress(100)
return "https://cdn.example.com/\(fileName)"
}
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadProjectImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { "" }
}
/// query
private func queryItems(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) }
})
}
/// JSON
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])
}