Files
suixinkan_ios_new/suixinkanTests/ProfileSecondaryPagesTests.swift
汉秋 5bdf4a7dbf 同步 Android 启动页到 iOS,并在冷启动期间静默恢复登录态。
新增 SplashView、SplashCoordinator 与 Launch Screen 资源,移除冷启动 Lottie;LoginView 加载 App 配置。同时将 token 存储迁移至 UserDefaults,并更新相关测试与文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 14:59:44 +08:00

423 lines
18 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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"))
}
/// 使
func testProfileDisplayFieldsUseCachedProfileBeforeUserInfoLoads() {
let viewModel = ProfileViewModel()
viewModel.applyCachedProfile(
AccountProfile(
userId: "101",
displayName: "缓存昵称",
phone: "13800000000",
avatarURL: "https://cdn.example.com/avatar.jpg"
)
)
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
XCTAssertEqual(viewModel.displayPhone, "13800000000")
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/avatar.jpg")
}
///
func testApplyCachedProfileIgnoredAfterUserInfoLoaded() {
let viewModel = ProfileViewModel()
viewModel.userInfo = UserInfoResponse(phone: "13900000000", nickname: "接口昵称")
viewModel.applyCachedProfile(
AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000")
)
XCTAssertEqual(viewModel.displayNickname, "接口昵称")
XCTAssertEqual(viewModel.displayPhone, "13900000000")
}
/// 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 uploadProjectImage(data: Data, fileName: String, 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))
}