Introduce real payment collection and wallet screens to replace home menu placeholders, wire APIs through RootView, and support bank card OSS uploads plus QR code saving to the photo library. Co-authored-by: Cursor <cursoragent@cursor.com>
389 lines
16 KiB
Swift
389 lines
16 KiB
Swift
//
|
||
// ProfileSecondaryPagesTests.swift
|
||
// suixinkanTests
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import UIKit
|
||
import XCTest
|
||
@testable import suixinkan
|
||
|
||
@MainActor
|
||
/// 个人中心二级页面测试,覆盖设置、账号切换和实名认证基础逻辑。
|
||
final class ProfileSecondaryPagesTests: XCTestCase {
|
||
/// 测试设置页版本号展示规则。
|
||
func testSettingsVersionTextUsesBuildNumberWhenNeeded() {
|
||
XCTAssertEqual(
|
||
SettingsDisplayPolicy.versionText(infoDictionary: [
|
||
"CFBundleShortVersionString": "1.2",
|
||
"CFBundleVersion": "45"
|
||
]),
|
||
"1.2.45"
|
||
)
|
||
XCTAssertEqual(
|
||
SettingsDisplayPolicy.versionText(infoDictionary: [
|
||
"CFBundleShortVersionString": "1.2.3",
|
||
"CFBundleVersion": "45"
|
||
]),
|
||
"1.2.3"
|
||
)
|
||
XCTAssertEqual(SettingsDisplayPolicy.versionText(infoDictionary: nil), "1.0.0")
|
||
}
|
||
|
||
/// 测试协议页面映射到预期 H5 路径。
|
||
func testAgreementPagesUseExpectedPaths() {
|
||
let pages: [(AgreementPage, String, String)] = [
|
||
(.about, "关于我们", "/h5/app/about-us"),
|
||
(.userAgreement, "用户协议", "/h5/app/user-agreement"),
|
||
(.privacyPolicy, "隐私政策", "/h5/app/privacy-policy"),
|
||
(.walletUserNotice, "钱包用户须知", "/h5/app/wallet-user-notice"),
|
||
(.walletPrivacy, "钱包隐私政策", "/h5/app/wallet-privacy")
|
||
]
|
||
|
||
for (page, title, path) in pages {
|
||
XCTAssertEqual(page.title, title)
|
||
XCTAssertEqual(page.id, title)
|
||
XCTAssertEqual(page.url.path, path)
|
||
}
|
||
}
|
||
|
||
/// 测试账号切换列表接口使用旧工程一致的 v9 accounts 路径。
|
||
func testSwitchableAccountsUsesExpectedEndpoint() async throws {
|
||
let session = ProfileSecondaryURLSession(responses: [
|
||
try TestFixture.data(named: "v9_login_multi_success")
|
||
])
|
||
let api = ProfileAPI(client: APIClient(session: session))
|
||
|
||
let response = try await api.switchableAccounts()
|
||
|
||
XCTAssertEqual(response.accounts.count, 2)
|
||
let request = try XCTUnwrap(session.requests.first)
|
||
XCTAssertEqual(request.httpMethod, "GET")
|
||
XCTAssertEqual(request.url?.path, "/api/app/v9/accounts")
|
||
}
|
||
|
||
/// 测试账号切换 ViewModel 能加载账号并默认选中第一项。
|
||
func testAccountSwitchViewModelLoadsAccounts() async throws {
|
||
let session = ProfileSecondaryURLSession(responses: [
|
||
try TestFixture.data(named: "v9_login_multi_success")
|
||
])
|
||
let api = ProfileAPI(client: APIClient(session: session))
|
||
let viewModel = AccountSwitchViewModel()
|
||
|
||
try await viewModel.load(api: api)
|
||
|
||
XCTAssertEqual(viewModel.accounts.map(\.businessUserId), [101, 201])
|
||
XCTAssertEqual(viewModel.selectedAccount?.businessUserId, 101)
|
||
XCTAssertFalse(viewModel.loading)
|
||
}
|
||
|
||
/// 测试账号切换提交使用 set-user 接口和账号对应请求体。
|
||
func testAccountSwitchViewModelSubmitsSetUser() async throws {
|
||
let session = ProfileSecondaryURLSession(responses: [
|
||
try TestFixture.data(named: "v9_set_store_user_success")
|
||
])
|
||
let api = AuthAPI(client: APIClient(session: session))
|
||
let account = AccountSwitchAccount(
|
||
accountType: V9StoreUser.accountTypeValue,
|
||
businessUserId: 201,
|
||
title: "示例门店",
|
||
subtitle: "示例景区",
|
||
phone: "13800138000",
|
||
avatar: "",
|
||
scenicName: "示例景区",
|
||
storeId: 20,
|
||
storeName: "示例门店",
|
||
scenicId: 10,
|
||
isCurrent: false
|
||
)
|
||
let viewModel = AccountSwitchViewModel()
|
||
|
||
let response = try await viewModel.switchAccount(account, api: api)
|
||
|
||
XCTAssertFalse(response.token.isEmpty)
|
||
let request = try XCTUnwrap(session.requests.first)
|
||
XCTAssertEqual(request.httpMethod, "POST")
|
||
XCTAssertEqual(request.url?.path, "/api/app/v9/set-user")
|
||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any])
|
||
XCTAssertEqual(json["store_user_id"] as? Int, 201)
|
||
XCTAssertNil(json["ss_user_id"] as? Int)
|
||
}
|
||
|
||
/// 测试实名认证信息解码兼容字符串数字字段。
|
||
func testRealNameInfoDecodesLossyFields() async throws {
|
||
let session = ProfileSecondaryURLSession(responses: [
|
||
try TestFixture.data(named: "real_name_info_success")
|
||
])
|
||
let api = ProfileAPI(client: APIClient(session: session))
|
||
|
||
let response = try await api.realNameInfo()
|
||
let info = try XCTUnwrap(response.realNameInfo)
|
||
|
||
XCTAssertEqual(info.realName, "测试摄影师")
|
||
XCTAssertEqual(info.idCardNo, "320100********0012")
|
||
XCTAssertTrue(info.verified)
|
||
XCTAssertFalse(info.isLongValid)
|
||
XCTAssertEqual(info.auditStatus, 2)
|
||
XCTAssertEqual(info.auditorId, 7)
|
||
XCTAssertEqual(info.auditor?.name, "审核员")
|
||
XCTAssertEqual(session.requests.first?.url?.path, "/api/yf-handset-app/photog/real-name/info")
|
||
}
|
||
|
||
/// 测试实名认证短信和提交接口路径及请求体。
|
||
func testRealNameSmsAndSubmitUseExpectedEndpoints() async throws {
|
||
let session = ProfileSecondaryURLSession(responses: [
|
||
try TestFixture.data(named: "empty_success"),
|
||
try TestFixture.data(named: "empty_success")
|
||
])
|
||
let api = ProfileAPI(client: APIClient(session: session))
|
||
|
||
try await api.realNameSmsVerifyCode()
|
||
try await api.realNameSubmit(
|
||
RealNameAuthRequest(
|
||
realName: "测试摄影师",
|
||
idCardNo: "11010519491231002X",
|
||
smsVerifyCode: "123456",
|
||
startDate: "2026-01-01",
|
||
endDate: "2046-01-01",
|
||
isLongValid: 0,
|
||
frontUrl: "https://cdn.example.com/front.jpg",
|
||
backUrl: "https://cdn.example.com/back.jpg"
|
||
)
|
||
)
|
||
|
||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||
"/api/yf-handset-app/photog/real-name/sms-verify-code",
|
||
"/api/yf-handset-app/photog/real-name/submit"
|
||
])
|
||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(session.requests.last?.httpBody)) as? [String: Any])
|
||
XCTAssertEqual(json["real_name"] as? String, "测试摄影师")
|
||
XCTAssertEqual(json["id_card_no"] as? String, "11010519491231002X")
|
||
XCTAssertEqual(json["sms_verify_code"] as? String, "123456")
|
||
XCTAssertEqual(json["is_long_valid"] as? Int, 0)
|
||
}
|
||
|
||
/// 测试身份证号校验和格式归一化。
|
||
func testRealNameIDCardValidation() {
|
||
XCTAssertTrue(RealNameAuthViewModel.isValidMainlandIDCardNumber("11010519491231002x"))
|
||
XCTAssertEqual(RealNameAuthViewModel.normalizedIDCardNumber(" 11010519491231002x "), "11010519491231002X")
|
||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("110105194912310021"))
|
||
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
|
||
}
|
||
|
||
/// 测试头像变更时先上传 OSS,再回写头像 URL。
|
||
func testProfileAvatarUploadUpdatesAvatarURL() async throws {
|
||
let session = ProfileSecondaryURLSession(responses: [
|
||
try TestFixture.data(named: "empty_success")
|
||
])
|
||
let api = ProfileAPI(client: APIClient(session: session))
|
||
let uploader = MockOSSUploader()
|
||
let viewModel = ProfileViewModel()
|
||
viewModel.userInfo = UserInfoResponse(avatar: "", nickname: "旧昵称")
|
||
viewModel.beginEditing()
|
||
try viewModel.prepareAvatarImage(data: makeJPEGData(), timestamp: 1_767_225_600)
|
||
|
||
try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10)
|
||
|
||
XCTAssertEqual(uploader.userAvatarUploads.count, 1)
|
||
XCTAssertEqual(uploader.userAvatarUploads.first?.scenicId, 10)
|
||
XCTAssertEqual(viewModel.userInfo?.avatar, uploader.avatarURL)
|
||
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update-avatar-url"])
|
||
}
|
||
|
||
/// 测试头像未变更时不会调用 OSS 上传。
|
||
func testProfileNicknameOnlySaveDoesNotUploadAvatar() async throws {
|
||
let session = ProfileSecondaryURLSession(responses: [
|
||
try TestFixture.data(named: "empty_success")
|
||
])
|
||
let api = ProfileAPI(client: APIClient(session: session))
|
||
let uploader = MockOSSUploader()
|
||
let viewModel = ProfileViewModel()
|
||
viewModel.userInfo = UserInfoResponse(avatar: "https://cdn.example.com/old.jpg", nickname: "旧昵称")
|
||
viewModel.beginEditing()
|
||
viewModel.editingNickname = "新昵称"
|
||
|
||
try await viewModel.saveProfile(api: api, uploader: uploader, scenicId: 10)
|
||
|
||
XCTAssertTrue(uploader.userAvatarUploads.isEmpty)
|
||
XCTAssertEqual(viewModel.userInfo?.nickname, "新昵称")
|
||
XCTAssertEqual(session.requests.map { $0.url?.path }, ["/api/yf-handset-app/userinfo-update"])
|
||
}
|
||
|
||
/// 测试实名认证提交前会上传正反面图片并提交最终 URL。
|
||
func testRealNameSubmitUploadsImagesBeforeSubmitting() async throws {
|
||
let session = ProfileSecondaryURLSession(responses: [
|
||
try TestFixture.data(named: "empty_success"),
|
||
try TestFixture.data(named: "real_name_info_success")
|
||
])
|
||
let api = ProfileAPI(client: APIClient(session: session))
|
||
let uploader = MockOSSUploader()
|
||
let viewModel = RealNameAuthViewModel()
|
||
viewModel.realName = "测试摄影师"
|
||
viewModel.idCardNo = "11010519491231002X"
|
||
viewModel.smsCode = "123456"
|
||
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front, timestamp: 1_767_225_600)
|
||
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back, timestamp: 1_767_225_601)
|
||
|
||
try await viewModel.submit(api: api, uploader: uploader, scenicId: 10)
|
||
|
||
XCTAssertEqual(uploader.realNameUploads.map(\.fileName), ["real_name_front_1767225600.jpg", "real_name_back_1767225601.jpg"])
|
||
XCTAssertEqual(session.requests.map { $0.url?.path }, [
|
||
"/api/yf-handset-app/photog/real-name/submit",
|
||
"/api/yf-handset-app/photog/real-name/info"
|
||
])
|
||
let body = try XCTUnwrap(session.requests.first?.httpBody)
|
||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any])
|
||
XCTAssertEqual(json["front_url"] as? String, uploader.realNameURLs[0])
|
||
XCTAssertEqual(json["back_url"] as? String, uploader.realNameURLs[1])
|
||
}
|
||
|
||
/// 测试实名认证缺少图片时阻止提交。
|
||
func testRealNameSubmitRequiresImages() async throws {
|
||
let api = ProfileAPI(client: APIClient(session: ProfileSecondaryURLSession(responses: [])))
|
||
let uploader = MockOSSUploader()
|
||
let viewModel = RealNameAuthViewModel()
|
||
viewModel.realName = "测试摄影师"
|
||
viewModel.idCardNo = "11010519491231002X"
|
||
viewModel.smsCode = "123456"
|
||
|
||
do {
|
||
try await viewModel.submit(api: api, uploader: uploader, scenicId: 10)
|
||
XCTFail("缺少证件图片时应阻止提交")
|
||
} catch {
|
||
XCTAssertEqual(error as? RealNameValidationError, .message("请选择身份证人像面图片"))
|
||
}
|
||
XCTAssertTrue(uploader.realNameUploads.isEmpty)
|
||
}
|
||
|
||
/// 测试实名认证图片上传失败时不会提交资料。
|
||
func testRealNameSubmitStopsWhenUploadFails() async throws {
|
||
let session = ProfileSecondaryURLSession(responses: [])
|
||
let api = ProfileAPI(client: APIClient(session: session))
|
||
let uploader = MockOSSUploader(error: OSSUploadError.unsupportedFileType)
|
||
let viewModel = RealNameAuthViewModel()
|
||
viewModel.realName = "测试摄影师"
|
||
viewModel.idCardNo = "11010519491231002X"
|
||
viewModel.smsCode = "123456"
|
||
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .front)
|
||
try viewModel.prepareIdentityImage(data: makeJPEGData(), side: .back)
|
||
|
||
do {
|
||
try await viewModel.submit(api: api, uploader: uploader, scenicId: 10)
|
||
XCTFail("上传失败时应阻止提交")
|
||
} catch {
|
||
XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType)
|
||
}
|
||
|
||
XCTAssertTrue(session.requests.isEmpty)
|
||
XCTAssertFalse(viewModel.submitting)
|
||
}
|
||
|
||
/// 测试首页系统设置 URI 已接入真实设置页。
|
||
func testHomeSystemSettingsRouteResolvesToSettingsPage() {
|
||
XCTAssertEqual(
|
||
HomeMenuRouter.resolve(uri: "system_settings", title: "设置中心"),
|
||
.destination(.settings)
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 个人中心测试用 URLSession 替身,按顺序返回预设响应。
|
||
private final class ProfileSecondaryURLSession: URLSessionProtocol {
|
||
private var responses: [Data]
|
||
private(set) var requests: [URLRequest] = []
|
||
|
||
/// 初始化顺序响应。
|
||
init(responses: [Data]) {
|
||
self.responses = responses
|
||
}
|
||
|
||
/// 记录请求并返回下一份响应。
|
||
func data(for request: URLRequest) async throws -> (Data, URLResponse) {
|
||
requests.append(request)
|
||
let data = responses.isEmpty ? Data() : responses.removeFirst()
|
||
return (
|
||
data,
|
||
HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 个人中心测试用 OSS 上传替身,记录上传请求并返回固定 URL。
|
||
private final class MockOSSUploader: OSSUploadServing {
|
||
struct UploadRecord: Equatable {
|
||
let fileName: String
|
||
let scenicId: Int
|
||
}
|
||
|
||
let avatarURL = "https://cdn.example.com/avatar-new.jpg"
|
||
let realNameURLs = [
|
||
"https://cdn.example.com/front-new.jpg",
|
||
"https://cdn.example.com/back-new.jpg"
|
||
]
|
||
private let error: Error?
|
||
private(set) var userAvatarUploads: [UploadRecord] = []
|
||
private(set) var realNameUploads: [UploadRecord] = []
|
||
|
||
/// 初始化上传替身,可选指定要抛出的错误。
|
||
init(error: Error? = nil) {
|
||
self.error = error
|
||
}
|
||
|
||
/// 模拟上传用户头像。
|
||
func uploadUserAvatar(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||
if let error { throw error }
|
||
userAvatarUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId))
|
||
onProgress(100)
|
||
return avatarURL
|
||
}
|
||
|
||
/// 模拟上传实名认证证件图片。
|
||
func uploadRealNameImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||
if let error { throw error }
|
||
realNameUploads.append(UploadRecord(fileName: fileName, scenicId: scenicId))
|
||
onProgress(100)
|
||
return realNameURLs[min(realNameUploads.count - 1, realNameURLs.count - 1)]
|
||
}
|
||
|
||
/// 模拟上传云盘文件。
|
||
func uploadCloudFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||
avatarURL
|
||
}
|
||
|
||
/// 模拟上传相册文件。
|
||
func uploadAlbumFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||
avatarURL
|
||
}
|
||
|
||
/// 模拟上传任务附件。
|
||
func uploadTaskFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||
avatarURL
|
||
}
|
||
|
||
/// 模拟上传打卡点图片。
|
||
func uploadPunchPointImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||
avatarURL
|
||
}
|
||
|
||
/// 模拟上传景区申请图片。
|
||
func uploadScenicApplicationImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||
avatarURL
|
||
}
|
||
|
||
/// 模拟上传银行卡照片。
|
||
func uploadBankCardImage(data: Data, fileName: String, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||
avatarURL
|
||
}
|
||
}
|
||
|
||
/// 生成测试用 JPEG 图片数据。
|
||
private func makeJPEGData() throws -> Data {
|
||
let renderer = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 12))
|
||
let image = renderer.image { context in
|
||
UIColor.systemBlue.setFill()
|
||
context.fill(CGRect(x: 0, y: 0, width: 12, height: 12))
|
||
}
|
||
return try XCTUnwrap(image.jpegData(compressionQuality: 0.9))
|
||
}
|