Migrate project management, schedule management, and photographer invitation flows from placeholders, including project image OSS upload and shared business models. Co-authored-by: Cursor <cursoragent@cursor.com>
234 lines
11 KiB
Swift
234 lines
11 KiB
Swift
//
|
||
// 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])
|
||
}
|