Implement report supplement evidence flow

This commit is contained in:
2026-07-08 17:23:28 +08:00
parent 290a01e699
commit fd5dc2bbe6
9 changed files with 1906 additions and 421 deletions

View File

@ -115,6 +115,129 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(response.list.first?.complaintNo, "JB20260708917")
}
func testReportDetailAPILoadsRemoteDetail() async throws {
let json = Data("""
{
"code": 100000,
"msg": "success",
"data": {
"id": 32,
"complaint_no": "JB20260708917",
"title": "",
"report_type": 1,
"report_type_text": "",
"desc": "",
"supplements": [],
"report_contents": [
{
"type": "initial",
"id": 0,
"desc": "",
"time": "2026-07-08 16:06:59",
"time_label": "16:06:59",
"evidences": [
{
"file_type": 1,
"file_type_text": "",
"file_url": "https://example.com/report.jpg",
"file_name": "report.jpg"
}
]
}
],
"scenic_id": 128,
"scenic_name": "伊犁那拉提景区-5A",
"store_user_id": 0,
"contact_phone": "13800138000",
"location_name": "伊犁那拉提景区-5A",
"location_address": "江苏省扬州市邗江区瘦西湖路",
"latitude": "32.4299428",
"longitude": "119.4403263",
"handle_status": 0,
"handle_status_text": "待处理",
"handle_remark": "",
"evidence_summary": { "image_count": 1, "video_count": 1 },
"evidences": [
{
"file_type": 1,
"file_type_text": "图片",
"file_url": "https://example.com/report.jpg",
"file_name": "report.jpg"
},
{
"file_type": 2,
"file_type_text": "视频",
"file_url": "https://example.com/report.mov",
"file_name": "report.mov"
}
],
"payment_screenshots": [
{
"file_type": 5,
"file_type_text": "支付截图",
"file_url": "https://example.com/payment.jpg",
"file_name": "payment.jpg"
}
],
"created_at": "2026-07-08 16:06:59",
"timeline": [
{ "time": "2026-07-08 16:06:59", "content": "摄影师提交举报" }
]
},
"time": "2026-07-08 16:31:50"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.reportDetail(id: 32)
let components = URLComponents(url: try XCTUnwrap(session.requests.first?.url), resolvingAgainstBaseURL: false)
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/detail")
XCTAssertTrue(components?.queryItems?.contains(URLQueryItem(name: "id", value: "32")) == true)
XCTAssertEqual(response.complaintNo, "JB20260708917")
XCTAssertEqual(response.evidences.count, 2)
XCTAssertEqual(response.paymentScreenshots.first?.fileName, "payment.jpg")
XCTAssertEqual(response.timeline.first?.content, "")
}
func testSupplementReportAPISendsRequestBody() async throws {
let json = Data("""
{
"code": 100000,
"msg": "success",
"data": null,
"time": "2026-07-08 17:31:50"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
try await api.supplementReport(WildReportSupplementRequest(
id: 32,
desc: "",
evidences: [
WildReportEvidenceRequest(
fileURL: "https://example.com/supplement.jpg",
fileType: 1,
fileName: "supplement.jpg"
)
]
))
let request = try XCTUnwrap(session.requests.first)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.url?.path, "/api/yf-handset-app/photog/report/supplement")
let bodyData = try XCTUnwrap(request.httpBody)
let body = try XCTUnwrap(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
XCTAssertEqual(body["id"] as? Int, 32)
XCTAssertEqual(body["desc"] as? String, "补充说明")
let evidences = try XCTUnwrap(body["evidences"] as? [[String: Any]])
XCTAssertEqual(evidences.first?["file_url"] as? String, "https://example.com/supplement.jpg")
XCTAssertEqual(evidences.first?["file_type"] as? Int, 1)
XCTAssertEqual(evidences.first?["file_name"] as? String, "supplement.jpg")
}
func testReportListItemMapsToRecord() {
let item = makeReportListItem(
complaintNo: "JB20260708917",
@ -136,6 +259,31 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(record.submitTime, "2026-07-08 16:06:59")
XCTAssertEqual(record.imageCount, 0)
XCTAssertEqual(record.videoCount, 0)
XCTAssertEqual(record.serverID, 32)
}
func testReportDetailMapsToRecordAndSupplementData() {
let detail = makeReportDetailData(handleStatus: 3, handleStatusText: "驳回")
let record = detail.toRecord()
XCTAssertEqual(record.serverID, 32)
XCTAssertEqual(record.id, "JB20260708917")
XCTAssertEqual(record.reportType, WildReportType(value: 1, label: "私下收款"))
XCTAssertEqual(record.status, .rejected)
XCTAssertEqual(record.location, "伊犁那拉提景区-5A · 江苏省扬州市邗江区瘦西湖路")
XCTAssertEqual(record.description, "正式详情说明")
XCTAssertEqual(record.phoneNumber, "13800138000")
XCTAssertEqual(record.imageCount, 1)
XCTAssertEqual(record.videoCount, 1)
XCTAssertEqual(detail.supplementRecords.first?.summaryText, "补充说明 · 1张图片")
}
func testReportStatusMapsRejected() {
XCTAssertEqual(WildReportStatus(handleStatus: 0), .pending)
XCTAssertEqual(WildReportStatus(handleStatus: 1), .processing)
XCTAssertEqual(WildReportStatus(handleStatus: 2), .processed)
XCTAssertEqual(WildReportStatus(handleStatus: 3), .rejected)
}
func testReportListAllFilterOmitsHandleStatus() {
@ -384,21 +532,165 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
}
func testSupplementEvidenceValidationAndSuccess() {
func testSupplementEvidenceValidationAndSuccess() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let reportID = homeViewModel.reports[0].id
let viewModel = WildReportSupplementEvidenceViewModel(reportID: reportID, homeViewModel: homeViewModel)
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
viewModel.submit()
await viewModel.submit(api: api, uploader: uploader, scenicId: 128)
XCTAssertEqual(viewModel.validationMessage, "请至少填写补充说明或上传一项证据。")
viewModel.updateText("补充说明")
viewModel.addImage()
viewModel.submit()
viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")])
viewModel.addVideos([try makeAttachment(kind: .video, fileName: "supplement.mov")])
await viewModel.submit(api: api, uploader: uploader, scenicId: 128)
XCTAssertTrue(viewModel.didSubmit)
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.text, "补充说明")
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: reportID).first?.imageCount, 1)
XCTAssertEqual(uploader.uploadedFileNames, ["supplement.jpg", "supplement.mov"])
XCTAssertEqual(api.supplementRequests.first?.id, 32)
XCTAssertEqual(api.supplementRequests.first?.desc, "补充说明")
XCTAssertEqual(api.supplementRequests.first?.evidences?.map(\.fileType), [1, 2])
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.text, "补充说明")
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.imageCount, 1)
XCTAssertEqual(homeViewModel.supplementaryEvidences(for: record.id).first?.videoCount, 1)
}
func testSupplementEvidenceFailsWithoutServerID() async {
let homeViewModel = WildPhotographerReportHomeViewModel()
let record = WildReportRecord(
serverID: nil,
id: "JBLOCAL",
title: "摄影师举报",
reportType: WildReportType(value: 2, label: "疑似打野"),
status: .pending,
location: "测试景区",
submitTime: "2026-07-08 16:06:59",
description: "本地记录",
wechatID: "",
phoneNumber: "",
imageCount: 1,
videoCount: 0,
handlerInfo: nil
)
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
viewModel.updateText("补充说明")
await viewModel.submit(
api: MockWildPhotographerReportAPI(),
uploader: MockWildReportAttachmentUploader(),
scenicId: 128
)
XCTAssertEqual(viewModel.validationMessage, "当前举报暂未同步到服务器,无法补充证据")
XCTAssertFalse(viewModel.didSubmit)
}
func testSupplementEvidenceTextAndAttachmentLimits() throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
viewModel.updateText(String(repeating: "", count: 520))
XCTAssertEqual(viewModel.text.count, 500)
let images = try (0..<10).map { try makeAttachment(kind: .image, fileName: "image\($0).jpg") }
viewModel.addImages(images)
XCTAssertEqual(viewModel.images.count, 9)
viewModel.removeAttachment(viewModel.images[0])
let videos = try (0..<4).map { try makeAttachment(kind: .video, fileName: "video\($0).mov") }
viewModel.addVideos(videos)
XCTAssertEqual(viewModel.videos.count, 1)
}
func testSupplementEvidenceUploadFailureDoesNotSubmit() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
let uploader = MockWildReportAttachmentUploader()
uploader.error = URLError(.cannotWriteToFile)
viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")])
await viewModel.submit(api: api, uploader: uploader, scenicId: 128)
XCTAssertFalse(viewModel.didSubmit)
XCTAssertTrue(api.supplementRequests.isEmpty)
XCTAssertEqual(viewModel.validationMessage, URLError(.cannotWriteToFile).localizedDescription)
}
func testSupplementEvidenceAPIErrorDoesNotSucceed() async throws {
let homeViewModel = WildPhotographerReportHomeViewModel()
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildReportSupplementEvidenceViewModel(record: record, homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
api.supplementError = URLError(.badServerResponse)
viewModel.addImages([try makeAttachment(kind: .image, fileName: "supplement.jpg")])
await viewModel.submit(
api: api,
uploader: MockWildReportAttachmentUploader(),
scenicId: 128
)
XCTAssertFalse(viewModel.didSubmit)
XCTAssertEqual(api.supplementRequests.count, 0)
XCTAssertEqual(viewModel.validationMessage, URLError(.badServerResponse).localizedDescription)
}
func testDetailViewModelLoadsRemoteDetailWhenServerIDExists() async {
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildPhotographerReportDetailViewModel(record: record)
let api = MockWildPhotographerReportAPI()
api.reportDetailResponses = [makeReportDetailData()]
await viewModel.loadDetailIfNeeded(api: api)
XCTAssertEqual(api.reportDetailRequests, [32])
XCTAssertEqual(viewModel.record.description, "正式详情说明")
XCTAssertEqual(viewModel.reporterMediaItems.count, 2)
XCTAssertEqual(viewModel.paymentScreenshotItems.count, 1)
XCTAssertEqual(viewModel.progressSteps.first?.title, "摄影师提交举报")
}
func testDetailViewModelFailureKeepsInitialRecord() async {
let record = makeReportListItem(complaintNo: "JB20260708917", handleStatus: 0).toRecord()
let viewModel = WildPhotographerReportDetailViewModel(record: record)
let api = MockWildPhotographerReportAPI()
api.reportDetailError = URLError(.badServerResponse)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.loadDetailIfNeeded(api: api)
XCTAssertEqual(viewModel.record.description, "测试说明")
XCTAssertEqual(messages.last, URLError(.badServerResponse).localizedDescription)
}
func testDetailViewModelWithoutServerIDSkipsRequest() async {
let record = WildReportRecord(
id: "JBLOCAL",
title: "摄影师举报",
reportType: WildReportType(value: 2, label: "疑似打野"),
status: .pending,
location: "测试景区",
submitTime: "2026-07-08 16:06:59",
description: "本地提交记录",
wechatID: "",
phoneNumber: "",
imageCount: 1,
videoCount: 0,
handlerInfo: nil
)
let viewModel = WildPhotographerReportDetailViewModel(record: record)
let api = MockWildPhotographerReportAPI()
await viewModel.loadDetailIfNeeded(api: api)
XCTAssertTrue(api.reportDetailRequests.isEmpty)
XCTAssertEqual(viewModel.record.description, "本地提交记录")
}
}
@ -410,6 +702,11 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
var reportListResponses: [WildReportListResponse] = []
var reportListRequests: [WildReportListRequest] = []
var reportListError: Error?
var reportDetailResponses: [WildReportDetailData] = []
var reportDetailRequests: [Int] = []
var reportDetailError: Error?
var supplementRequests: [WildReportSupplementRequest] = []
var supplementError: Error?
func reportTypes() async throws -> WildReportTypeListResponse {
if let error { throw error }
@ -421,6 +718,11 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
submitRequests.append(request)
}
func supplementReport(_ request: WildReportSupplementRequest) async throws {
if let supplementError { throw supplementError }
supplementRequests.append(request)
}
func reportList(_ request: WildReportListRequest) async throws -> WildReportListResponse {
if let reportListError { throw reportListError }
reportListRequests.append(request)
@ -429,6 +731,13 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
}
return reportListResponses.removeFirst()
}
func reportDetail(id: Int) async throws -> WildReportDetailData {
if let reportDetailError { throw reportDetailError }
reportDetailRequests.append(id)
guard !reportDetailResponses.isEmpty else { return WildReportDetailData(id: id) }
return reportDetailResponses.removeFirst()
}
}
@MainActor
@ -462,6 +771,7 @@ private func makeAttachment(kind: WildReportAttachmentKind, fileName: String) th
}
private func makeReportListItem(
id: Int = 32,
complaintNo: String,
title: String = "疑似打野摄影师举报",
reportType: Int = 2,
@ -470,7 +780,7 @@ private func makeReportListItem(
handleStatusText: String = "待处理"
) -> WildReportListItem {
WildReportListItem(
id: Int(complaintNo.filter(\.isNumber)) ?? 1,
id: id,
complaintNo: complaintNo,
title: title,
reportType: reportType,
@ -486,3 +796,73 @@ private func makeReportListItem(
createdAt: "2026-07-08 16:06:59"
)
}
private func makeReportDetailData(
handleStatus: Int = 0,
handleStatusText: String = "待处理"
) -> WildReportDetailData {
WildReportDetailData(
id: 32,
complaintNo: "JB20260708917",
title: "私下收款举报",
reportType: 1,
reportTypeText: "私下收款",
desc: "正式详情说明",
supplements: [
WildReportDetailContent(
type: "supplement",
id: 7,
desc: "补充说明",
time: "2026-07-08 17:06:59",
timeLabel: "17:06:59",
evidences: [
WildReportDetailEvidence(
fileType: 1,
fileTypeText: "图片",
fileURL: "https://example.com/supplement.jpg",
fileName: "supplement.jpg"
)
]
)
],
reportContents: [],
scenicId: 128,
scenicName: "伊犁那拉提景区-5A",
storeUserId: 0,
contactPhone: "13800138000",
locationName: "伊犁那拉提景区-5A",
locationAddress: "江苏省扬州市邗江区瘦西湖路",
latitude: "32.4299428",
longitude: "119.4403263",
handleStatus: handleStatus,
handleStatusText: handleStatusText,
handleRemark: "处理备注",
evidenceSummary: WildReportEvidenceSummary(imageCount: 1, videoCount: 1),
evidences: [
WildReportDetailEvidence(
fileType: 1,
fileTypeText: "图片",
fileURL: "https://example.com/report.jpg",
fileName: "report.jpg"
),
WildReportDetailEvidence(
fileType: 2,
fileTypeText: "视频",
fileURL: "https://example.com/report.mov",
fileName: "report.mov"
)
],
paymentScreenshots: [
WildReportDetailEvidence(
fileType: 5,
fileTypeText: "支付截图",
fileURL: "https://example.com/payment.jpg",
fileName: "payment.jpg"
)
],
timeline: [
WildReportDetailTimelineItem(time: "2026-07-08 16:06:59", content: "摄影师提交举报")
],
createdAt: "2026-07-08 16:06:59"
)
}