feat: update wallet punch point and report success

This commit is contained in:
2026-07-09 14:27:18 +08:00
parent 2970f1514b
commit 43e6133c21
34 changed files with 6671 additions and 71 deletions

View File

@ -41,6 +41,13 @@ final class HomeViewModelTests: XCTestCase {
XCTAssertFalse(viewModel.isMinimalTopRole)
}
func testLocationReportPromptDefaultsToFixedHomeCopy() {
let viewModel = HomeViewModel(appStore: appStore)
XCTAssertEqual(viewModel.locationInfoText, HomeViewModel.locationReportPromptText)
XCTAssertEqual(viewModel.locationInfoText, "您已进入打卡范围")
}
func testRebuildCommonMenusFiltersUnknownURIs() {
appStore.savePermissionItems([
HomePermissionItem(id: "1", name: "钱包", uri: "wallet"),

View File

@ -0,0 +1,93 @@
//
// PunchPointAPITests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// API
@MainActor
final class PunchPointAPITests: XCTestCase {
func testListAndInfoUseAndroidPathsAndQueries() async throws {
let session = MockURLSession(responses: [
envelopeJSON(#"{"total":0,"list":[]}"#),
envelopeJSON(#"{"id":18,"name":""}"#),
])
let api = PunchPointAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.list(scenicAreaId: "9", status: 2, page: 3, pageSize: 10)
_ = try await api.info(id: 18)
XCTAssertEqual(session.requests[0].httpMethod, "GET")
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/scenic-spot/list")
let listQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(listQuery?.first { $0.name == "scenic_area_id" }?.value, "9")
XCTAssertEqual(listQuery?.first { $0.name == "status" }?.value, "2")
XCTAssertEqual(listQuery?.first { $0.name == "page" }?.value, "3")
XCTAssertEqual(listQuery?.first { $0.name == "page_size" }?.value, "10")
XCTAssertEqual(session.requests[1].httpMethod, "GET")
XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/scenic-spot/info")
let infoQuery = URLComponents(url: try XCTUnwrap(session.requests[1].url), resolvingAgainstBaseURL: false)?.queryItems
XCTAssertEqual(infoQuery?.first { $0.name == "id" }?.value, "18")
}
func testAddEditAndDeleteEncodeAndroidBodies() async throws {
let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}"), envelopeJSON("{}")])
let api = PunchPointAPI(client: APIClient(environment: .testing, session: session))
let addRequest = PunchPointSaveRequest(
scenicAreaId: "7",
name: "山顶打卡点",
description: "日出位置",
region: PunchPointSaveRegion(lot: 120.12, lat: 30.23, address: "山顶平台"),
scenicSpotString: "山顶平台",
guideImages: ["https://cdn/a.jpg"]
)
let editRequest = PunchPointSaveRequest(
id: 88,
scenicAreaId: "7",
name: "山顶打卡点2",
description: nil,
region: PunchPointSaveRegion(lot: 121.12, lat: 31.23, address: "新平台"),
scenicSpotString: "新平台",
guideImages: ["https://cdn/b.jpg"]
)
try await api.add(addRequest)
try await api.edit(editRequest)
try await api.delete(id: 88)
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/scenic-spot/add")
let addBody = try bodyJSON(session.requests[0])
XCTAssertNil(addBody["id"])
XCTAssertEqual(addBody["scenic_area_id"] as? String, "7")
XCTAssertEqual(addBody["name"] as? String, "山顶打卡点")
XCTAssertEqual(addBody["scenic_spot_str"] as? String, "山顶平台")
XCTAssertEqual(addBody["guide_imgs"] as? [String], ["https://cdn/a.jpg"])
let addRegion = try XCTUnwrap(addBody["region"] as? [String: Any])
XCTAssertEqual(addRegion["lot"] as? Double, 120.12)
XCTAssertEqual(addRegion["lat"] as? Double, 30.23)
XCTAssertEqual(addRegion["address"] as? String, "山顶平台")
XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/scenic-spot/edit")
let editBody = try bodyJSON(session.requests[1])
XCTAssertEqual(editBody["id"] as? Int, 88)
XCTAssertEqual(editBody["name"] as? String, "山顶打卡点2")
XCTAssertNil(editBody["description"])
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/scenic-spot/delete")
let deleteBody = try bodyJSON(session.requests[2])
XCTAssertEqual(deleteBody["id"] as? Int, 88)
}
private func envelopeJSON(_ dataJSON: String) -> Data {
"""
{"code":100000,"msg":"success","data":\(dataJSON)}
""".data(using: .utf8)!
}
private func bodyJSON(_ request: URLRequest) throws -> [String: Any] {
try XCTUnwrap(JSONSerialization.jsonObject(with: try XCTUnwrap(request.httpBody)) as? [String: Any])
}
}

View File

@ -0,0 +1,85 @@
//
// PunchPointModelsTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
///
final class PunchPointModelsTests: XCTestCase {
func testListItemDecodesRegionImagesQRCodeAndAuditFields() throws {
let json = """
{
"total": 1,
"list": [{
"id": "9001",
"name": "",
"status": 1,
"status_label": "",
"region": {"lat": 30.1, "lot": 120.2, "address": "", "scenic_spot_str": ""},
"scenic_spot_str": "",
"guide_imgs": ["https://cdn/1.jpg", "https://cdn/2.jpg"],
"mp_qrcode": "https://cdn/qr.png",
"created_at": "2026-07-08 10:00:00",
"creator_id": "77",
"creator": "张三",
"creator_phone": "13800138000",
"auditor": "李四",
"audit_time": "2026-07-08 11:00:00",
"audit_remark": "通过"
}]
}
""".data(using: .utf8)!
let response = try JSONDecoder().decode(PunchPointListResponse.self, from: json)
XCTAssertEqual(response.total, 1)
let item = try XCTUnwrap(response.list.first)
XCTAssertEqual(item.id, 9001)
XCTAssertEqual(item.statusLabel, "")
XCTAssertEqual(item.region?.lat, 30.1)
XCTAssertEqual(item.region?.lot, 120.2)
XCTAssertEqual(item.displayAddress, "")
XCTAssertEqual(item.guideImages.count, 2)
XCTAssertEqual(item.mpQrcode, "https://cdn/qr.png")
XCTAssertEqual(item.creatorId, 77)
XCTAssertEqual(item.auditTime, "2026-07-08 11:00:00")
XCTAssertEqual(PunchPointDisplayFormatter.maskedPhone(item.creatorPhone), "138****8000")
}
func testDetailDecodesDefaultsAndCoordinateFallbacks() throws {
let json = """
{
"id": 12,
"name": "",
"status": 3,
"region": {"lat": 31.2, "lot": 121.3, "address": ""},
"scenic_spot_str": "",
"description": ""
}
""".data(using: .utf8)!
let detail = try JSONDecoder().decode(PunchPointDetail.self, from: json)
XCTAssertEqual(detail.statusLabel, "")
XCTAssertEqual(detail.guideImages, [])
XCTAssertNil(detail.mpQrcode)
XCTAssertEqual(detail.displayAddress, "景区入口")
XCTAssertEqual(detail.coordinate?.latitude, 31.2)
XCTAssertEqual(detail.coordinate?.longitude, 121.3)
XCTAssertEqual(detail.description, "集合点")
XCTAssertEqual(PunchPointFilterType.rejected.apiStatus, 4)
XCTAssertEqual(PunchPointFilterType.pendingReview.title, "未审核")
}
func testImageRemoteStateUsesURLAndUploadedFlag() {
let image = PunchPointImageState.remote(url: "https://cdn.example.com/path/a.jpg")
XCTAssertEqual(image.fileName, "a.jpg")
XCTAssertEqual(image.previewURL, "https://cdn.example.com/path/a.jpg")
XCTAssertEqual(image.uploadedURL, "https://cdn.example.com/path/a.jpg")
XCTAssertTrue(image.isUploaded)
XCTAssertFalse(image.isUploading)
}
}

View File

@ -0,0 +1,259 @@
//
// PunchPointViewModelTests.swift
// suixinkanTests
//
import CoreLocation
import XCTest
@testable import suixinkan
/// ViewModel
@MainActor
final class PunchPointViewModelTests: XCTestCase {
func testFilterRefreshAndPaginationUseAndroidStatusAndPageSize() async {
let api = MockPunchPointAPI()
api.listResponses = [
PunchPointListResponse(total: 12, list: (1...10).map { item(id: Int64($0)) }),
PunchPointListResponse(total: 12, list: [item(id: 11), item(id: 12)]),
PunchPointListResponse(total: 1, list: [item(id: 21)]),
]
let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 })
await viewModel.loadInitial(api: api)
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 8, api: api)
await viewModel.selectFilter(.paused, api: api)
XCTAssertEqual(viewModel.items.map(\.id), [21])
XCTAssertEqual(api.listCalls.map(\.status), [0, 0, 2])
XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1])
XCTAssertEqual(api.listCalls.map(\.pageSize), [10, 10, 10])
XCTAssertEqual(api.listCalls.map(\.scenicAreaId), ["9", "9", "9"])
}
func testDeleteSuccessRemovesItemAndQRCodeValidationMatchesAndroid() async {
let api = MockPunchPointAPI()
api.listResponses = [PunchPointListResponse(total: 2, list: [
item(id: 1, status: 1, mpQrcode: "https://cdn/qr.png"),
item(id: 2, status: 3, mpQrcode: nil),
])]
let viewModel = PunchPointListViewModel(currentScenicIdProvider: { 9 })
var messages: [String] = []
var qrcode: String?
viewModel.onShowMessage = { messages.append($0) }
viewModel.onShowQRCode = { qrcode = $0 }
await viewModel.loadInitial(api: api)
viewModel.showQRCode(for: viewModel.items[1])
viewModel.showQRCode(for: PunchPointItem(id: 3, status: 1, mpQrcode: " "))
viewModel.showQRCode(for: viewModel.items[0])
await viewModel.delete(item: viewModel.items[0], api: api)
XCTAssertEqual(messages, ["未审核通过不可查看二维码", "暂无二维码"])
XCTAssertEqual(qrcode, "https://cdn/qr.png")
XCTAssertEqual(viewModel.items.map(\.id), [2])
XCTAssertEqual(api.deletedIds, [1])
}
func testFormValidationUploadAndCreateRequestBody() async {
let api = MockPunchPointAPI()
let uploader = MockPunchPointUploader(resultURL: "https://cdn/upload.jpg")
let location = MockPunchPointLocationProvider(reverseAddress: "地图地址")
let viewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location)
var messages: [String] = []
viewModel.onShowMessage = { messages.append($0) }
await viewModel.submit(api: api)
viewModel.updateName("湖边打卡")
await viewModel.selectCoordinate(CLLocationCoordinate2D(latitude: 30.1, longitude: 120.2))
viewModel.updateDescription(String(repeating: "", count: 80))
await viewModel.addLocalImages([PunchPointImageState(data: Data([1, 2]), fileName: "a.jpg")], uploader: uploader)
await viewModel.submit(api: api)
XCTAssertEqual(messages.first, "请输入打卡点名称")
XCTAssertEqual(viewModel.description.count, 50)
XCTAssertEqual(uploader.uploads.map(\.moduleFileName), ["a.jpg"])
let request = api.addRequests.first
XCTAssertEqual(request?.scenicAreaId, "7")
XCTAssertEqual(request?.name, "湖边打卡")
XCTAssertEqual(request?.region.lat, 30.1)
XCTAssertEqual(request?.region.lot, 120.2)
XCTAssertEqual(request?.region.address, "地图地址")
XCTAssertEqual(request?.scenicSpotString, "地图地址")
XCTAssertEqual(request?.guideImages, ["https://cdn/upload.jpg"])
}
func testEditInitializationAndSubmitUsesEditAPI() async {
let api = MockPunchPointAPI()
let viewModel = PunchPointFormViewModel(mode: .edit(id: 66), currentScenicIdProvider: { 8 }, locationProvider: MockPunchPointLocationProvider())
var changedCoordinate: CLLocationCoordinate2D?
viewModel.onCoordinateChange = { changedCoordinate = $0 }
viewModel.initialize(with: PunchPointDetail(
id: 66,
name: "旧打卡点",
region: PunchPointRegion(lat: 31.1, lot: 121.1, address: "旧地址"),
description: "说明",
guideImages: ["https://cdn/old.jpg"]
))
viewModel.updateName("新打卡点")
await viewModel.submit(api: api)
XCTAssertEqual(changedCoordinate?.latitude, 31.1)
XCTAssertEqual(viewModel.images.first?.uploadedURL, "https://cdn/old.jpg")
XCTAssertEqual(api.editRequests.first?.id, 66)
XCTAssertEqual(api.editRequests.first?.name, "新打卡点")
XCTAssertEqual(api.editRequests.first?.guideImages, ["https://cdn/old.jpg"])
}
func testUploadingAndFailedImagesBlockSubmit() async {
let api = MockPunchPointAPI()
let slowUploader = SlowPunchPointUploader()
let location = MockPunchPointLocationProvider(reverseAddress: "地址")
let uploadingViewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location)
uploadingViewModel.updateName("上传中")
await uploadingViewModel.selectCoordinate(CLLocationCoordinate2D(latitude: 1, longitude: 2))
let started = expectation(description: "upload started")
slowUploader.onStart = { started.fulfill() }
let task = Task {
await uploadingViewModel.addLocalImages([PunchPointImageState(data: Data([1]), fileName: "slow.jpg")], uploader: slowUploader)
}
await fulfillment(of: [started], timeout: 1)
var uploadMessages: [String] = []
uploadingViewModel.onShowMessage = { uploadMessages.append($0) }
await uploadingViewModel.submit(api: api)
task.cancel()
let failedViewModel = PunchPointFormViewModel(currentScenicIdProvider: { 7 }, locationProvider: location)
failedViewModel.updateName("失败图")
await failedViewModel.selectCoordinate(CLLocationCoordinate2D(latitude: 1, longitude: 2))
await failedViewModel.addLocalImages(
[PunchPointImageState(data: Data([1]), fileName: "fail.jpg")],
uploader: MockPunchPointUploader(error: URLError(.cannotConnectToHost))
)
var failedMessages: [String] = []
failedViewModel.onShowMessage = { failedMessages.append($0) }
await failedViewModel.submit(api: api)
XCTAssertEqual(uploadMessages.last, "图片正在上传,请稍后提交")
XCTAssertEqual(failedMessages.last, "请先完成图片上传")
XCTAssertTrue(api.addRequests.isEmpty)
}
private func item(id: Int64, status: Int = 1, mpQrcode: String? = nil) -> PunchPointItem {
PunchPointItem(
id: id,
name: "\(id)",
status: status,
statusLabel: status == 1 ? "运营中" : "未审核",
region: PunchPointRegion(lat: 30, lot: 120, address: "地址\(id)"),
guideImages: ["https://cdn/\(id).jpg"],
mpQrcode: mpQrcode
)
}
}
@MainActor
private final class MockPunchPointAPI: PunchPointAPIProtocol {
var listResponses: [PunchPointListResponse] = []
var listCalls: [(scenicAreaId: String, status: Int, page: Int, pageSize: Int)] = []
var addRequests: [PunchPointSaveRequest] = []
var editRequests: [PunchPointSaveRequest] = []
var deletedIds: [Int64] = []
func list(scenicAreaId: String, status: Int, page: Int, pageSize: Int) async throws -> PunchPointListResponse {
listCalls.append((scenicAreaId, status, page, pageSize))
guard !listResponses.isEmpty else { return PunchPointListResponse() }
return listResponses.removeFirst()
}
func info(id: Int64) async throws -> PunchPointDetail {
PunchPointDetail(id: id)
}
func add(_ request: PunchPointSaveRequest) async throws {
addRequests.append(request)
}
func edit(_ request: PunchPointSaveRequest) async throws {
editRequests.append(request)
}
func delete(id: Int64) async throws {
deletedIds.append(id)
}
}
@MainActor
private final class MockPunchPointUploader: PunchPointImageUploading {
struct Upload {
let moduleFileName: String
let scenicId: Int
}
var uploads: [Upload] = []
let resultURL: String
let error: Error?
init(resultURL: String = "https://cdn/default.jpg", error: Error? = nil) {
self.resultURL = resultURL
self.error = error
}
func uploadPunchPointImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
uploads.append(Upload(moduleFileName: fileName, scenicId: scenicId))
onProgress(100)
if let error {
throw error
}
return resultURL
}
}
@MainActor
private final class SlowPunchPointUploader: PunchPointImageUploading {
var onStart: (() -> Void)?
func uploadPunchPointImage(
data: Data,
fileName: String,
scenicId: Int,
onProgress: @escaping (Int) -> Void
) async throws -> String {
onProgress(10)
onStart?()
try await Task.sleep(nanoseconds: 1_000_000_000)
onProgress(100)
return "https://cdn/slow.jpg"
}
}
private final class MockPunchPointLocationProvider: LocationProviding, @unchecked Sendable {
let snapshot: HomeLocationSnapshot
let reverseAddress: String
init(
snapshot: HomeLocationSnapshot = HomeLocationSnapshot(latitude: 30.2, longitude: 120.3, address: "当前位置"),
reverseAddress: String = "反查地址"
) {
self.snapshot = snapshot
self.reverseAddress = reverseAddress
}
func requestSnapshot() async throws -> HomeLocationSnapshot {
snapshot
}
func requestCoordinate(desiredAccuracy: CLLocationAccuracy) async throws -> CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: snapshot.latitude, longitude: snapshot.longitude)
}
func reverseGeocode(latitude: Double, longitude: Double) async -> String {
reverseAddress
}
}

View File

@ -84,4 +84,26 @@ final class TravelAlbumOTGCoreTests: XCTestCase {
XCTAssertTrue(key.hasPrefix("album/20260707/9/11111111222233334444555555555555_"))
XCTAssertTrue(key.hasSuffix("A_B.JPG"))
}
func testOSSPunchPointObjectKeyUsesAndroidUserPath() {
let previousUserId = AppStore.shared.userId
AppStore.shared.userId = "u900"
defer { AppStore.shared.userId = previousUserId }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let date = calendar.date(from: DateComponents(year: 2026, month: 7, day: 9))!
let uuid = UUID(uuidString: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")!
let key = OSSUploadPolicy.objectKey(
fileName: "guide/photo.jpg",
scenicId: 9,
moduleType: "punch_point",
date: date,
uuid: uuid,
timeZone: TimeZone(secondsFromGMT: 0)!
)
XCTAssertEqual(key, "punch_point/20260709/u900/aaaaaaaabbbbccccddddeeeeeeeeeeee_guide_photo.jpg")
}
}

View File

@ -0,0 +1,272 @@
//
// WalletFeatureTests.swift
// suixinkanTests
//
import XCTest
@testable import suixinkan
/// API ViewModel
@MainActor
final class WalletFeatureTests: XCTestCase {
func testWalletModelsDecodeLossyFields() throws {
let withdrawJSON = Data(#"{"total":"2","item":[{"id":"10","amount":88.5,"created_at":"2026-07-01","settlement_status":"40","withdraw_status":1,"expected_at":"T+1","status_label":""}]}"#.utf8)
let withdraw = try JSONDecoder().decode(WalletWithdrawListResponse.self, from: withdrawJSON)
XCTAssertEqual(withdraw.total, 2)
XCTAssertEqual(withdraw.item[0].id, 10)
XCTAssertEqual(withdraw.item[0].amount, "88.5")
XCTAssertEqual(withdraw.item[0].settlementStatus, 40)
let earningJSON = Data(#"{"total_amount":12.3,"total_points":"7","total":"1","list":[{"date":"2026-07-01","day_amount":"12.30","day_points":"7","items":[{"id":"1","amount":"12.30","points":"7","type_label":"","order_number_suffix":"001","created_at":"10:00","withdraw_label":"","source":"wallet"}]}]}"#.utf8)
let earning = try JSONDecoder().decode(EarningDetailResponse.self, from: earningJSON)
XCTAssertEqual(earning.totalAmount, "12.3")
XCTAssertEqual(earning.totalPoints, 7)
XCTAssertEqual(earning.list[0].items[0].points, 7)
let pointJSON = Data(#"{"total":"1","list":[{"id":"2","points":"24","amount":"2.0","status":"2","payment_time":"2026-07-02","created_at":"2026-07-01"}]}"#.utf8)
let points = try JSONDecoder().decode(PointWithdrawListResponse.self, from: pointJSON)
XCTAssertEqual(points.total, 1)
XCTAssertEqual(points.list[0].points, 24)
XCTAssertEqual(points.list[0].amount, 2.0)
}
func testWalletAPIUsesExpectedRequests() async throws {
let session = MockURLSession(responses: [
Self.envelope(#"{"amount_total":"100.00","amount_current_balance":"80.00","amount_withdrawable":"50.00"}"#),
Self.envelope(#"{"total":0,"item":[]}"#),
Self.envelope(#"{"total_amount":"0.00","total_points":0,"total":0,"list":[]}"#),
Self.envelope(#"{"amount_withdrawable":"50.00","min_withdraw_amount":"1","max_single_withdraw_amount":"100","max_daily_withdraw_amount":"200","user_phone":"13800138000","bank_card":{"real_name":"","bank_name":"","card_number":"622200001234"},"settlement":"T+1","withdraw_info":[""]}"#),
Self.envelope(#"{}"#),
Self.envelope(#"{}"#),
Self.envelope(#"{"total_points":100,"available_points":80,"withdrawn_points":60,"pending_points":20,"time":"2026"}"#),
Self.envelope(#"{}"#),
Self.envelope(#"{"total":0,"list":[]}"#),
])
let api = WalletAPI(client: APIClient(environment: .testing, session: session))
_ = try await api.walletSummary(type: 0)
_ = try await api.walletWithdrawList(page: 0, pageSize: 0)
_ = try await api.earningDetail(startDate: "2026-07-01", endDate: "2026-07-09", page: 2, pageSize: 10)
_ = try await api.withdrawInfo()
try await api.withdrawSendSMS()
try await api.withdrawApply(amount: "12.30", smsCode: "123456")
_ = try await api.pointOverview(staffId: 9)
try await api.pointWithdrawApply(points: 24, remark: "积分提现申请")
_ = try await api.pointWithdrawList(status: nil, page: 1, pageSize: 10)
XCTAssertEqual(session.requests.map { $0.url?.path }, [
"/api/yf-handset-app/photog/wallet/summary",
"/api/yf-handset-app/photog/wallet/withdraw-list",
"/api/yf-handset-app/photog/wallet/earning-detail",
"/api/yf-handset-app/photog/wallet/withdraw-info",
"/api/yf-handset-app/photog/wallet/withdraw-send-sms",
"/api/yf-handset-app/photog/wallet/withdraw-apply",
"/api/yf-handset-app/photog/point/overview",
"/api/yf-handset-app/photog/point/withdraw/apply",
"/api/yf-handset-app/photog/point/withdraw/list",
])
XCTAssertEqual(Self.queryItems(from: session.requests[1])["page"], "1")
XCTAssertEqual(Self.queryItems(from: session.requests[2])["start_date"], "2026-07-01")
XCTAssertEqual(Self.queryItems(from: session.requests[6])["staff_id"], "9")
XCTAssertEqual(Self.bodyString(from: session.requests[5]).contains(#""sms_code":"123456""#), true)
XCTAssertEqual(Self.bodyString(from: session.requests[7]).contains(#""points":24"#), true)
XCTAssertEqual(Self.bodyString(from: session.requests[8]).contains(#""page_size":10"#), true)
}
func testWalletViewModelLoadsAndPaginates() async {
let api = FakeWalletPageService()
let viewModel = WalletViewModel(staffIdProvider: { 9 })
await viewModel.loadInitial(api: api)
XCTAssertEqual(viewModel.summary?.amountWithdrawable, "50.00")
XCTAssertEqual(viewModel.availablePoints, 80)
XCTAssertEqual(viewModel.withdrawRecords.count, 1)
XCTAssertEqual(viewModel.selectedTab, .withdraw)
await viewModel.selectTab(.transaction, api: api)
XCTAssertEqual(viewModel.transactionEntries.count, 2)
XCTAssertEqual(viewModel.totalIncomeLabel, "¥ 12.30")
await viewModel.selectFilter(.last30Days, api: api)
XCTAssertEqual(viewModel.filter, .last30Days)
XCTAssertGreaterThanOrEqual(api.earningQueries.count, 2)
}
func testWalletWithdrawDestinationFlow() async throws {
let viewModel = WalletViewModel()
var profile = FakeWalletProfileService(realNameStatus: nil, bankCardStatus: nil)
var destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .realNameAuth)
profile = FakeWalletProfileService(realNameStatus: 1, bankCardStatus: nil)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .realNamePending)
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: nil)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .withdrawalSettings)
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 1)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .bankCardPending)
profile = FakeWalletProfileService(realNameStatus: 2, bankCardStatus: 2)
destination = try await viewModel.resolveWithdrawDestination(profileAPI: profile)
XCTAssertEqual(destination, .withdraw)
}
func testWithdrawViewModelValidatesAndSubmits() async {
let api = FakeWalletPageService()
let viewModel = WithdrawViewModel()
await viewModel.load(api: api)
viewModel.updateAmount("0012.345")
viewModel.updateVerificationCode("12a456")
XCTAssertEqual(viewModel.amount, "12.34")
XCTAssertEqual(viewModel.verificationCode, "12456")
let success = await viewModel.submit(api: api)
XCTAssertTrue(success)
XCTAssertEqual(api.withdrawApplyRequests.first?.amount, "12.34")
}
func testPointsRedemptionViewModelClampsAndRefreshes() async {
let api = FakeWalletPageService()
let viewModel = PointsRedemptionViewModel(staffIdProvider: { 9 })
await viewModel.loadInitial(api: api)
viewModel.updatePointsInput("999")
XCTAssertEqual(viewModel.pointsInput, "60")
await viewModel.applyWithdraw(api: api)
XCTAssertEqual(api.pointApplyRequests, [60])
XCTAssertEqual(viewModel.pointsInput, "")
XCTAssertEqual(viewModel.withdrawRecords.count, 1)
}
private static func envelope(_ dataJSON: String) -> Data {
Data(#"{"code":100000,"msg":"success","data":\#(dataJSON)}"#.utf8)
}
private static func queryItems(from request: URLRequest) -> [String: String] {
URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?
.queryItems?
.reduce(into: [String: String]()) { result, item in
result[item.name] = item.value
} ?? [:]
}
private static func bodyString(from request: URLRequest) -> String {
String(data: request.httpBody ?? Data(), encoding: .utf8) ?? ""
}
}
@MainActor
///
private final class FakeWalletPageService: WalletPageServing {
private(set) var earningQueries: [(start: String, end: String, page: Int)] = []
private(set) var withdrawApplyRequests: [(amount: String, smsCode: String)] = []
private(set) var pointApplyRequests: [Int] = []
func walletSummary(type: Int) async throws -> WalletSummaryResponse {
WalletSummaryResponse(amountTotal: "100.00", amountCurrentBalance: "80.00", amountWithdrawable: "50.00")
}
func walletTransactionList(
startDate: String,
endDate: String,
page: Int,
pageSize: Int,
type: Int
) async throws -> WalletTransactionListResponse {
WalletTransactionListResponse()
}
func walletWithdrawList(page: Int, pageSize: Int) async throws -> WalletWithdrawListResponse {
WalletWithdrawListResponse(
total: 1,
item: [WalletWithdrawItem(id: page, amount: "10.00", createdAt: "2026-07-01", settlementStatus: 40, statusLabel: "打款中")]
)
}
func earningDetail(startDate: String, endDate: String, page: Int, pageSize: Int) async throws -> EarningDetailResponse {
earningQueries.append((startDate, endDate, page))
return EarningDetailResponse(
totalAmount: "12.30",
totalPoints: 7,
total: 1,
list: [
EarningDetailGroup(
date: "2026-07-01",
dayAmount: "12.30",
dayPoints: 7,
items: [EarningDetailItem(id: page, amount: "12.30", points: 7, typeLabel: "订单收益", createdAt: "10:00", withdrawLabel: "可提现")]
),
]
)
}
func withdrawInfo() async throws -> WithdrawInfoResponse {
WithdrawInfoResponse(
amountWithdrawable: "50.00",
minWithdrawAmount: "1",
maxSingleWithdrawAmount: "100",
maxDailyWithdrawAmount: "200",
userPhone: "13800138000",
bankCard: WithdrawBankCardInfo(realName: "张三", bankName: "招商银行", cardNumber: "622200001234"),
settlement: "T+1",
withdrawInfo: ["说明"]
)
}
func withdrawSendSMS() async throws {}
func withdrawApply(amount: String, smsCode: String) async throws {
withdrawApplyRequests.append((amount, smsCode))
}
func pointOverview(staffId: Int) async throws -> PointOverviewResponse {
PointOverviewResponse(totalPoints: 100, availablePoints: 80, withdrawnPoints: 60, pendingPoints: 20)
}
func pointWithdrawApply(points: Int, remark: String) async throws {
pointApplyRequests.append(points)
}
func pointWithdrawList(status: Int?, page: Int, pageSize: Int) async throws -> PointWithdrawListResponse {
PointWithdrawListResponse(total: 1, list: [PointWithdrawItem(id: page, points: 24, amount: 2, status: 1, createdAt: "2026-07-01")])
}
}
@MainActor
///
private struct FakeWalletProfileService: WalletProfileServing {
let realNameStatus: Int?
let bankCardStatus: Int?
func realNameInfo() async throws -> RealNameInfoResponse {
let data: Data = if let realNameStatus {
Data(#"{"real_name_info":{"real_name":"","id_card_no":"110101199001010010","audit_status":\#(realNameStatus),"is_long_valid":1}}"#.utf8)
} else {
Data(#"{"real_name_info":null}"#.utf8)
}
return try JSONDecoder().decode(RealNameInfoResponse.self, from: data)
}
func bankCardInfo() async throws -> BankCardInfoResponse {
let data: Data = if let bankCardStatus {
Data(#"{"bank_card":{"real_name":"","bank_name":"","branch_name":"","card_number":"622200001234","audit_status":\#(bankCardStatus),"audit_status_label":""}}"#.utf8)
} else {
Data(#"{"bank_card":null}"#.utf8)
}
return try JSONDecoder().decode(BankCardInfoResponse.self, from: data)
}
}

View File

@ -158,6 +158,40 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertEqual(response.list.first?.complaintNo, "JB20260708917")
}
func testSubmitReportAPIReturnsComplaintNumber() async throws {
let json = Data("""
{
"code": 100000,
"data": {
"complaint_no": "JB20260709886",
"id": 40
},
"msg": "success",
"time": "2026-07-09 14:09:41"
}
""".utf8)
let session = MockURLSession(responses: [json])
let api = WildPhotographerReportAPI(client: APIClient(environment: .testing, session: session))
let response = try await api.submitReport(WildReportSubmitRequest(
scenicId: 128,
reportType: 2,
desc: "测试说明",
storeUserId: nil,
contactPhone: "13800138000",
locationName: "测试景区",
locationAddress: "测试地址",
lat: 30.12,
lng: 120.34,
evidences: [WildReportEvidenceRequest(fileURL: "https://example.com/scene.jpg", fileType: 1, fileName: "scene.jpg")],
paymentScreenshots: []
))
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/report/submit")
XCTAssertEqual(response.id, 40)
XCTAssertEqual(response.complaintNo, "JB20260709886")
}
func testReportDetailAPILoadsRemoteDetail() async throws {
let json = Data("""
{
@ -548,6 +582,7 @@ final class WildPhotographerReportTests: XCTestCase {
let homeViewModel = WildPhotographerReportHomeViewModel()
let viewModel = WildPhotographerReportSubmitViewModel(homeViewModel: homeViewModel)
let api = MockWildPhotographerReportAPI()
api.submitResponse = WildReportSubmitResponse(id: 40, complaintNo: "JB20260709886")
let uploader = MockWildReportAttachmentUploader()
viewModel.selectReportType(WildReportType(value: 2, label: ""))
viewModel.addImages([try makeAttachment(kind: .image, fileName: "scene.jpg")])
@ -559,7 +594,8 @@ final class WildPhotographerReportTests: XCTestCase {
XCTAssertNil(viewModel.validationMessage)
XCTAssertNotNil(viewModel.submitResult)
XCTAssertEqual(viewModel.submitResult?.record.id, "")
XCTAssertEqual(viewModel.submitResult?.record.serverID, 40)
XCTAssertEqual(viewModel.submitResult?.record.id, "JB20260709886")
XCTAssertEqual(uploader.uploadedFileNames, ["scene.jpg", "payment.jpg"])
XCTAssertEqual(api.submitRequests.first?.scenicId, 9)
XCTAssertEqual(api.submitRequests.first?.reportType, 2)
@ -1043,6 +1079,7 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
var reportCopyResponse = WildReportCopyResponse()
var reportCopyError: Error?
var error: Error?
var submitResponse = WildReportSubmitResponse()
var submitRequests: [WildReportSubmitRequest] = []
var reportListResponses: [WildReportListResponse] = []
var reportListRequests: [WildReportListRequest] = []
@ -1069,9 +1106,10 @@ private final class MockWildPhotographerReportAPI: WildPhotographerReportServing
return reportCopyResponse
}
func submitReport(_ request: WildReportSubmitRequest) async throws {
func submitReport(_ request: WildReportSubmitRequest) async throws -> WildReportSubmitResponse {
if let error { throw error }
submitRequests.append(request)
return submitResponse
}
func supplementReport(_ request: WildReportSupplementRequest) async throws {