// // 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] = [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 { 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 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]) }