feat: update wallet punch point and report success
This commit is contained in:
@ -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"),
|
||||
|
||||
93
suixinkanTests/PunchPointAPITests.swift
Normal file
93
suixinkanTests/PunchPointAPITests.swift
Normal 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])
|
||||
}
|
||||
}
|
||||
85
suixinkanTests/PunchPointModelsTests.swift
Normal file
85
suixinkanTests/PunchPointModelsTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
259
suixinkanTests/PunchPointViewModelTests.swift
Normal file
259
suixinkanTests/PunchPointViewModelTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
272
suixinkanTests/WalletFeatureTests.swift
Normal file
272
suixinkanTests/WalletFeatureTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user