// // 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 } } /// 生成测试用 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)) }