新增 OSS 上传与 Kingfisher 图片加载支持
This commit is contained in:
@ -5,6 +5,7 @@
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import UIKit
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@ -170,6 +171,114 @@ final class ProfileSecondaryPagesTests: XCTestCase {
|
||||
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(
|
||||
@ -199,3 +308,76 @@ private final class ProfileSecondaryURLSession: URLSessionProtocol {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 个人中心测试用 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))
|
||||
}
|
||||
|
||||
113
suixinkanTests/UploadTests.swift
Normal file
113
suixinkanTests/UploadTests.swift
Normal file
@ -0,0 +1,113 @@
|
||||
//
|
||||
// UploadTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 上传模块测试,覆盖 OSS 上传策略和 STS 配置接口。
|
||||
final class UploadTests: XCTestCase {
|
||||
/// 测试文件校验能识别空文件、超大文件和不支持扩展名。
|
||||
func testOSSUploadPolicyValidatesFileConstraints() {
|
||||
XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 0, fileName: "avatar.jpg")) { error in
|
||||
XCTAssertEqual(error as? OSSUploadError, .emptyFile)
|
||||
}
|
||||
XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: OSSUploadPolicy.maxFileSize + 1, fileName: "avatar.jpg")) { error in
|
||||
XCTAssertEqual(error as? OSSUploadError, .fileTooLarge)
|
||||
}
|
||||
XCTAssertThrowsError(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.txt")) { error in
|
||||
XCTAssertEqual(error as? OSSUploadError, .unsupportedFileType)
|
||||
}
|
||||
XCTAssertNoThrow(try OSSUploadPolicy.validate(dataSize: 12, fileName: "avatar.heic"))
|
||||
}
|
||||
|
||||
/// 测试 objectKey 按业务模块生成稳定路径并清洗文件名。
|
||||
func testOSSUploadPolicyBuildsObjectKeys() {
|
||||
let date = Date(timeIntervalSince1970: 1_767_225_600)
|
||||
let uuid = UUID(uuidString: "12345678-1234-1234-1234-1234567890ab")!
|
||||
let timeZone = TimeZone(secondsFromGMT: 0)!
|
||||
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "a/b.jpg", scenicId: 9, moduleType: "user_avatar", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"avatar/20260101/9/123456781234123412341234567890AB_a_b.jpg"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "id.jpg", scenicId: 9, moduleType: "real_name", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"real_name/20260101/9/123456781234123412341234567890AB_id.jpg"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
OSSUploadPolicy.objectKey(fileName: "video.mp4", scenicId: 9, moduleType: "task_upload", date: date, uuid: uuid, timeZone: timeZone),
|
||||
"task_upload/20260101/9/123456781234123412341234567890AB_video.mp4"
|
||||
)
|
||||
}
|
||||
|
||||
/// 测试 MIME 类型推断和 URL 拼接规则。
|
||||
func testOSSUploadPolicyContentTypeAndURLJoin() {
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.jpg", fileType: 2), "image/jpeg")
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.png", fileType: 2), "image/png")
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.mp4", fileType: 1), "video/mp4")
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 1), "video/mp4")
|
||||
XCTAssertEqual(OSSUploadPolicy.contentType(for: "a.unknown", fileType: 2), "image/jpeg")
|
||||
XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg")
|
||||
XCTAssertEqual(OSSUploadPolicy.joinURL(baseURL: "https://cdn.example.com/", objectKey: "a/b.jpg"), "https://cdn.example.com/a/b.jpg")
|
||||
}
|
||||
|
||||
/// 测试 STS token 接口使用正确路径、bucket 参数并兼容宽松字段类型。
|
||||
func testUploadAPIRequestsSTSConfig() async throws {
|
||||
let response = """
|
||||
{
|
||||
"code": 100000,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"base_url": 123,
|
||||
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
|
||||
"region": "cn-hangzhou",
|
||||
"bucket": "vipsky",
|
||||
"expire_seconds": "900",
|
||||
"credentials": {
|
||||
"access_key_id": 456,
|
||||
"access_key_secret": "secret",
|
||||
"security_token": "token"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
let session = UploadURLSession(responses: [response])
|
||||
let api = UploadAPI(client: APIClient(session: session))
|
||||
|
||||
let config = try await api.aliyunOSSBucket()
|
||||
|
||||
XCTAssertEqual(config.baseUrl, "123")
|
||||
XCTAssertEqual(config.expireSeconds, 900)
|
||||
XCTAssertEqual(config.credentials.accessKeyId, "456")
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/config/get-sts-token")
|
||||
XCTAssertEqual(URLComponents(url: request.url!, resolvingAgainstBaseURL: false)?.queryItems?.first?.value, "vipsky")
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传测试用 URLSession 替身,按顺序返回预设响应。
|
||||
private final class UploadURLSession: 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)!
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user