feat: 添加云盘与消息中心功能
This commit is contained in:
71
suixinkanTests/CloudDriveAPITests.swift
Normal file
71
suixinkanTests/CloudDriveAPITests.swift
Normal file
@ -0,0 +1,71 @@
|
||||
//
|
||||
// CloudDriveAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 相册云盘 API 测试。
|
||||
final class CloudDriveAPITests: XCTestCase {
|
||||
func testCloudDriveRequestsMatchAndroidPaths() async throws {
|
||||
let session = MockURLSession(responses: [
|
||||
envelopeJSON(#"{"total":0,"list":[]}"#),
|
||||
envelopeJSON("{}"),
|
||||
envelopeJSON("{}"),
|
||||
envelopeJSON("{}"),
|
||||
envelopeJSON("{}"),
|
||||
envelopeJSON(#"{"can_upload":false,"reason":"空间不足"}"#),
|
||||
envelopeJSON("{}"),
|
||||
envelopeJSON("{}"),
|
||||
])
|
||||
let api = CloudDriveAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
_ = try await api.cloudFileList(parentFolderId: 4, name: "abc", type: 2, orderBy: 1, page: 3, pageSize: 20)
|
||||
try await api.createCloudFolder(parentFolderId: 4, name: "新文件夹")
|
||||
try await api.uploadCloudFile(parentFolderId: 4, fileUrl: "https://cdn/a.jpg", fileName: "a.jpg")
|
||||
try await api.deleteCloudFiles(CloudFileDeleteRequest(list: [CloudFileOperationItem(id: 7, type: 2)]))
|
||||
try await api.moveCloudFiles(CloudFileMoveRequest(targetFolderId: 9, list: [CloudFileOperationItem(id: 7, type: 2)]))
|
||||
_ = try await api.checkUploadPermission()
|
||||
try await api.modifyCloudFolderName(fileId: 8, name: "目录")
|
||||
try await api.modifyCloudFileName(fileId: 7, fileName: "b.jpg")
|
||||
|
||||
XCTAssertEqual(session.requests[0].httpMethod, "GET")
|
||||
XCTAssertEqual(session.requests[0].url?.path, "/api/yf-handset-app/photog/cloud-driver/list")
|
||||
let query = URLComponents(url: session.requests[0].url!, resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(query?.first { $0.name == "parent_folder_id" }?.value, "4")
|
||||
XCTAssertEqual(query?.first { $0.name == "name" }?.value, "abc")
|
||||
XCTAssertEqual(query?.first { $0.name == "type" }?.value, "2")
|
||||
XCTAssertEqual(query?.first { $0.name == "order_by" }?.value, "1")
|
||||
XCTAssertEqual(query?.first { $0.name == "page" }?.value, "3")
|
||||
|
||||
XCTAssertEqual(session.requests[1].url?.path, "/api/yf-handset-app/photog/cloud-driver/folder-create")
|
||||
let folderBody = try bodyObject(session.requests[1])
|
||||
XCTAssertEqual(folderBody["parent_folder_id"] as? Int, 4)
|
||||
XCTAssertEqual(folderBody["name"] as? String, "新文件夹")
|
||||
|
||||
XCTAssertEqual(session.requests[2].url?.path, "/api/yf-handset-app/photog/cloud-driver/file-upload")
|
||||
let uploadBody = try bodyObject(session.requests[2])
|
||||
XCTAssertEqual(uploadBody["file_url"] as? String, "https://cdn/a.jpg")
|
||||
XCTAssertEqual(uploadBody["file_name"] as? String, "a.jpg")
|
||||
|
||||
XCTAssertEqual(session.requests[3].url?.path, "/api/yf-handset-app/photog/cloud-driver/delete")
|
||||
XCTAssertEqual(session.requests[4].url?.path, "/api/yf-handset-app/photog/cloud-driver/move")
|
||||
let moveBody = try bodyObject(session.requests[4])
|
||||
XCTAssertEqual(moveBody["target_folder_id"] as? Int, 9)
|
||||
XCTAssertEqual(session.requests[5].url?.path, "/api/yf-handset-app/photog/cloud-driver/check-upload-permission")
|
||||
XCTAssertEqual(session.requests[6].url?.path, "/api/yf-handset-app/photog/cloud-driver/folder-edit")
|
||||
XCTAssertEqual(session.requests[7].url?.path, "/api/yf-handset-app/photog/cloud-driver/file-edit")
|
||||
}
|
||||
|
||||
private func bodyObject(_ request: URLRequest) throws -> [String: Any] {
|
||||
try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(request.httpBody)) as? [String: Any])
|
||||
}
|
||||
|
||||
private func envelopeJSON(_ dataJSON: String) -> Data {
|
||||
"""
|
||||
{"code":100000,"msg":"success","data":\(dataJSON)}
|
||||
""".data(using: .utf8)!
|
||||
}
|
||||
}
|
||||
139
suixinkanTests/CloudDriveViewModelTests.swift
Normal file
139
suixinkanTests/CloudDriveViewModelTests.swift
Normal file
@ -0,0 +1,139 @@
|
||||
//
|
||||
// CloudDriveViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 相册云盘 ViewModel 测试。
|
||||
final class CloudDriveViewModelTests: XCTestCase {
|
||||
func testLoadMoreSearchFilterSortAndFolderNavigation() async throws {
|
||||
let api = MockCloudDriveAPI()
|
||||
api.listResponses = [
|
||||
CloudFileListResponse(total: 3, list: [file(id: 1), folder(id: 2)]),
|
||||
CloudFileListResponse(total: 3, list: [file(id: 3)]),
|
||||
CloudFileListResponse(total: 1, list: [file(id: 4)]),
|
||||
CloudFileListResponse(total: 0, list: []),
|
||||
]
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
|
||||
await viewModel.refresh(api: api)
|
||||
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 1, api: api)
|
||||
viewModel.updateSearchText("图")
|
||||
await viewModel.chooseFilterType(.image, api: api)
|
||||
_ = await viewModel.handleFileTap(folder(id: 9), api: api)
|
||||
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [])
|
||||
XCTAssertEqual(viewModel.pathStack.map(\.id), [0, 9])
|
||||
XCTAssertEqual(api.listCalls.map(\.page), [1, 2, 1, 1])
|
||||
XCTAssertEqual(api.listCalls[2].name, "图")
|
||||
XCTAssertEqual(api.listCalls[2].type, 2)
|
||||
}
|
||||
|
||||
func testBackRestoresCachedRootAndGoHome() async {
|
||||
let api = MockCloudDriveAPI()
|
||||
api.listResponses = [
|
||||
CloudFileListResponse(total: 1, list: [folder(id: 2)]),
|
||||
CloudFileListResponse(total: 1, list: [file(id: 5)]),
|
||||
]
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
await viewModel.refresh(api: api)
|
||||
_ = await viewModel.handleFileTap(folder(id: 2), api: api)
|
||||
|
||||
let handled = await viewModel.navigateBack(api: api)
|
||||
|
||||
XCTAssertTrue(handled)
|
||||
XCTAssertEqual(viewModel.pathStack.map(\.id), [0])
|
||||
XCTAssertEqual(viewModel.files.map(\.id), [2])
|
||||
XCTAssertEqual(api.listCalls.count, 2)
|
||||
}
|
||||
|
||||
func testCreateDeleteMoveAndRenameRefresh() async {
|
||||
let api = MockCloudDriveAPI()
|
||||
api.listResponses = Array(repeating: CloudFileListResponse(), count: 5)
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
|
||||
await viewModel.addFolder(name: " 新目录 ", api: api)
|
||||
viewModel.setControlFile(file(id: 7, type: 2))
|
||||
await viewModel.deleteCurrentFile(api: api)
|
||||
await viewModel.moveCurrentFile(targetFolderId: 9, api: api)
|
||||
await viewModel.saveCurrentFileName(" 新名.jpg ", api: api)
|
||||
viewModel.setControlFile(folder(id: 8))
|
||||
await viewModel.saveCurrentFileName(" 新目录名 ", api: api)
|
||||
|
||||
XCTAssertEqual(api.createFolderCalls.first?.name, "新目录")
|
||||
XCTAssertEqual(api.deleteCalls.first?.list.first?.id, 7)
|
||||
XCTAssertEqual(api.moveCalls.first?.targetFolderId, 9)
|
||||
XCTAssertEqual(api.renameFileCalls.first?.fileName, "新名.jpg")
|
||||
XCTAssertEqual(api.renameFolderCalls.first?.name, "新目录名")
|
||||
}
|
||||
|
||||
func testUploadPermissionFailureShowsReason() async {
|
||||
let api = MockCloudDriveAPI()
|
||||
api.permission = CloudUploadPermissionResponse(canUpload: false, reason: "空间不足")
|
||||
let viewModel = CloudDriveListViewModel()
|
||||
var message: String?
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
|
||||
let canUpload = await viewModel.checkCanUpload(api: api)
|
||||
|
||||
XCTAssertFalse(canUpload)
|
||||
XCTAssertEqual(message, "空间不足")
|
||||
}
|
||||
|
||||
private func file(id: Int, type: Int = 2) -> CloudFile {
|
||||
CloudFile(id: id, fileUrl: "https://cdn/\(id).jpg", name: "文件\(id)", type: type)
|
||||
}
|
||||
|
||||
private func folder(id: Int) -> CloudFile {
|
||||
CloudFile(id: id, name: "目录\(id)", type: 99)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockCloudDriveAPI: CloudDriveServing {
|
||||
var listResponses: [CloudFileListResponse] = []
|
||||
var permission = CloudUploadPermissionResponse(canUpload: true, reason: "")
|
||||
var listCalls: [(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int)] = []
|
||||
var createFolderCalls: [(parentFolderId: Int, name: String)] = []
|
||||
var uploadCalls: [(parentFolderId: Int, fileUrl: String, fileName: String)] = []
|
||||
var deleteCalls: [CloudFileDeleteRequest] = []
|
||||
var moveCalls: [CloudFileMoveRequest] = []
|
||||
var renameFolderCalls: [(fileId: Int, name: String)] = []
|
||||
var renameFileCalls: [(fileId: Int, fileName: String)] = []
|
||||
|
||||
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> CloudFileListResponse {
|
||||
listCalls.append((parentFolderId, name, type, orderBy, page, pageSize))
|
||||
return listResponses.isEmpty ? CloudFileListResponse() : listResponses.removeFirst()
|
||||
}
|
||||
|
||||
func createCloudFolder(parentFolderId: Int, name: String) async throws {
|
||||
createFolderCalls.append((parentFolderId, name))
|
||||
}
|
||||
|
||||
func uploadCloudFile(parentFolderId: Int, fileUrl: String, fileName: String) async throws {
|
||||
uploadCalls.append((parentFolderId, fileUrl, fileName))
|
||||
}
|
||||
|
||||
func deleteCloudFiles(_ request: CloudFileDeleteRequest) async throws {
|
||||
deleteCalls.append(request)
|
||||
}
|
||||
|
||||
func moveCloudFiles(_ request: CloudFileMoveRequest) async throws {
|
||||
moveCalls.append(request)
|
||||
}
|
||||
|
||||
func checkUploadPermission() async throws -> CloudUploadPermissionResponse {
|
||||
permission
|
||||
}
|
||||
|
||||
func modifyCloudFolderName(fileId: Int, name: String) async throws {
|
||||
renameFolderCalls.append((fileId, name))
|
||||
}
|
||||
|
||||
func modifyCloudFileName(fileId: Int, fileName: String) async throws {
|
||||
renameFileCalls.append((fileId, fileName))
|
||||
}
|
||||
}
|
||||
185
suixinkanTests/CloudTransferManagerTests.swift
Normal file
185
suixinkanTests/CloudTransferManagerTests.swift
Normal file
@ -0,0 +1,185 @@
|
||||
//
|
||||
// CloudTransferManagerTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
@MainActor
|
||||
/// 云盘传输管理测试。
|
||||
final class CloudTransferManagerTests: XCTestCase {
|
||||
func testUploadRegistersFileAndRemovesCompletedTask() async throws {
|
||||
let store = MemoryTransferStore()
|
||||
let uploader = FakeCloudUploader(url: "https://cdn/a.jpg")
|
||||
let api = TransferMockCloudDriveAPI()
|
||||
let manager = CloudTransferManager(
|
||||
store: store,
|
||||
uploader: uploader,
|
||||
api: api,
|
||||
downloader: FakeDownloader(),
|
||||
photoSaver: FakePhotoSaver(),
|
||||
scenicIdProvider: { 10 }
|
||||
)
|
||||
let fileURL = try makeTempFile(name: "a.jpg")
|
||||
var completedFolder: Int?
|
||||
manager.onUploadCompleted = { completedFolder = $0 }
|
||||
|
||||
manager.addUploadTask(localFileURL: fileURL, fileName: "a.jpg", fileType: 2, fileSize: 3, parentFolderId: 6)
|
||||
try await waitUntil { manager.uploadTasks.isEmpty }
|
||||
|
||||
XCTAssertEqual(api.uploadCalls.first?.parentFolderId, 6)
|
||||
XCTAssertEqual(api.uploadCalls.first?.fileUrl, "https://cdn/a.jpg")
|
||||
XCTAssertEqual(completedFolder, 6)
|
||||
XCTAssertTrue(store.saved.last?.allSatisfy { $0.kind != .upload } == true)
|
||||
}
|
||||
|
||||
func testUploadFailureKeepsFailedTask() async throws {
|
||||
let uploader = FakeCloudUploader(error: TestError.failed)
|
||||
let manager = CloudTransferManager(
|
||||
store: MemoryTransferStore(),
|
||||
uploader: uploader,
|
||||
api: TransferMockCloudDriveAPI(),
|
||||
downloader: FakeDownloader(),
|
||||
photoSaver: FakePhotoSaver(),
|
||||
scenicIdProvider: { 10 }
|
||||
)
|
||||
let fileURL = try makeTempFile(name: "a.jpg")
|
||||
|
||||
manager.addUploadTask(localFileURL: fileURL, fileName: "a.jpg", fileType: 2, fileSize: 3, parentFolderId: 0)
|
||||
try await waitUntil { manager.uploadTasks.first?.status == .failed }
|
||||
|
||||
XCTAssertEqual(manager.uploadTasks.first?.status, .failed)
|
||||
}
|
||||
|
||||
func testDownloadCompletesAndCanCancel() async throws {
|
||||
let downloader = FakeDownloader()
|
||||
let saver = FakePhotoSaver()
|
||||
let manager = CloudTransferManager(
|
||||
store: MemoryTransferStore(),
|
||||
uploader: FakeCloudUploader(url: "unused"),
|
||||
api: TransferMockCloudDriveAPI(),
|
||||
downloader: downloader,
|
||||
photoSaver: saver,
|
||||
scenicIdProvider: { 10 }
|
||||
)
|
||||
|
||||
manager.addDownloadTask(fileURL: "https://cdn/a.jpg", fileName: "a.jpg", fileType: 2)
|
||||
try await waitUntil { manager.downloadTasks.first?.status == .completed }
|
||||
|
||||
XCTAssertEqual(downloader.downloadCalls.first?.remoteURL, "https://cdn/a.jpg")
|
||||
XCTAssertEqual(saver.saved.count, 1)
|
||||
XCTAssertEqual(manager.downloadTasks.first?.progress, 100)
|
||||
|
||||
let id = try XCTUnwrap(manager.downloadTasks.first?.id)
|
||||
manager.cancelDownload(id: id)
|
||||
XCTAssertTrue(manager.downloadTasks.isEmpty)
|
||||
}
|
||||
|
||||
private func makeTempFile(name: String) throws -> URL {
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + name)
|
||||
try Data([1, 2, 3]).write(to: url)
|
||||
return url
|
||||
}
|
||||
|
||||
private func waitUntil(
|
||||
timeout: TimeInterval = 2,
|
||||
condition: @escaping () -> Bool
|
||||
) async throws {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
while !condition() {
|
||||
if Date() > deadline {
|
||||
XCTFail("Timed out waiting for condition")
|
||||
return
|
||||
}
|
||||
try await Task.sleep(nanoseconds: 20_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class MemoryTransferStore: CloudTransferTaskStoring {
|
||||
var tasks: [CloudTransferTask] = []
|
||||
var saved: [[CloudTransferTask]] = []
|
||||
|
||||
func loadTasks() -> [CloudTransferTask] {
|
||||
tasks
|
||||
}
|
||||
|
||||
func saveTasks(_ tasks: [CloudTransferTask]) {
|
||||
self.tasks = tasks
|
||||
saved.append(tasks)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class FakeCloudUploader: CloudDriveUploading {
|
||||
let url: String
|
||||
let error: Error?
|
||||
|
||||
init(url: String = "", error: Error? = nil) {
|
||||
self.url = url
|
||||
self.error = error
|
||||
}
|
||||
|
||||
func uploadCloudDriveFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String {
|
||||
if let error { throw error }
|
||||
onProgress(50)
|
||||
onProgress(100)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakeDownloader: CloudFileDownloading {
|
||||
var downloadCalls: [(remoteURL: String, fileName: String)] = []
|
||||
|
||||
func downloadFile(remoteURL: String, fileName: String, onProgress: @escaping @Sendable (Int) -> Void) async throws -> URL {
|
||||
downloadCalls.append((remoteURL, fileName))
|
||||
onProgress(40)
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + fileName)
|
||||
try Data([1]).write(to: url)
|
||||
onProgress(100)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
private final class FakePhotoSaver: CloudPhotoSaving {
|
||||
var saved: [(fileURL: URL, fileType: Int)] = []
|
||||
|
||||
func saveToPhotoLibrary(fileURL: URL, fileType: Int) async throws {
|
||||
saved.append((fileURL, fileType))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class TransferMockCloudDriveAPI: CloudDriveServing {
|
||||
var uploadCalls: [(parentFolderId: Int, fileUrl: String, fileName: String)] = []
|
||||
|
||||
func cloudFileList(parentFolderId: Int, name: String, type: Int, orderBy: Int, page: Int, pageSize: Int) async throws -> CloudFileListResponse {
|
||||
CloudFileListResponse()
|
||||
}
|
||||
|
||||
func createCloudFolder(parentFolderId: Int, name: String) async throws {}
|
||||
|
||||
func uploadCloudFile(parentFolderId: Int, fileUrl: String, fileName: String) async throws {
|
||||
uploadCalls.append((parentFolderId, fileUrl, fileName))
|
||||
}
|
||||
|
||||
func deleteCloudFiles(_ request: CloudFileDeleteRequest) async throws {}
|
||||
|
||||
func moveCloudFiles(_ request: CloudFileMoveRequest) async throws {}
|
||||
|
||||
func checkUploadPermission() async throws -> CloudUploadPermissionResponse {
|
||||
CloudUploadPermissionResponse(canUpload: true, reason: "")
|
||||
}
|
||||
|
||||
func modifyCloudFolderName(fileId: Int, name: String) async throws {}
|
||||
|
||||
func modifyCloudFileName(fileId: Int, fileName: String) async throws {}
|
||||
}
|
||||
|
||||
private enum TestError: LocalizedError {
|
||||
case failed
|
||||
|
||||
var errorDescription: String? { "failed" }
|
||||
}
|
||||
83
suixinkanTests/MessageCenterAPITests.swift
Normal file
83
suixinkanTests/MessageCenterAPITests.swift
Normal file
@ -0,0 +1,83 @@
|
||||
//
|
||||
// MessageCenterAPITests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 消息中心 API 路径、参数与模型解码测试。
|
||||
@MainActor
|
||||
final class MessageCenterAPITests: XCTestCase {
|
||||
func testListBuildsAndroidQueryAndDecodesMessagePayload() async throws {
|
||||
let payload = """
|
||||
{
|
||||
"has_more": true,
|
||||
"last_id": 18,
|
||||
"is_refresh": false,
|
||||
"items": [{
|
||||
"id": 21,
|
||||
"receiver_id": 9,
|
||||
"receiver_type": "staff",
|
||||
"type": 2,
|
||||
"type_name": "退款成功通知",
|
||||
"title": "unused",
|
||||
"content": "退款已完成",
|
||||
"push_at": "2026-07-09 10:00:00",
|
||||
"is_read": false,
|
||||
"read_at": "",
|
||||
"push_channel": 1,
|
||||
"push_channel_name": "站内信",
|
||||
"extra_data": {"order_no":"NO123","amount":12.5},
|
||||
"created_at": "2026-07-09 10:00:00",
|
||||
"updated_at": "2026-07-09 10:01:00"
|
||||
}]
|
||||
}
|
||||
"""
|
||||
let session = MockURLSession(responses: [envelopeJSON(payload)])
|
||||
let api = MessageCenterAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
let response = try await api.list(lastId: 7, limit: 20, unread: 0)
|
||||
|
||||
let request = try XCTUnwrap(session.requests.first)
|
||||
XCTAssertEqual(request.httpMethod, "GET")
|
||||
XCTAssertEqual(request.url?.path, "/api/app/msg/list")
|
||||
let query = URLComponents(url: try XCTUnwrap(request.url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(query?.first { $0.name == "last_id" }?.value, "7")
|
||||
XCTAssertEqual(query?.first { $0.name == "limit" }?.value, "20")
|
||||
XCTAssertEqual(query?.first { $0.name == "unread" }?.value, "0")
|
||||
XCTAssertTrue(response.hasMore)
|
||||
XCTAssertEqual(response.lastId, 18)
|
||||
XCTAssertEqual(response.items.first?.id, 21)
|
||||
XCTAssertEqual(response.items.first?.isRead, false)
|
||||
XCTAssertEqual(response.items.first?.extraData?["order_no"], .string("NO123"))
|
||||
}
|
||||
|
||||
func testReadAndDeleteUseAndroidPathsAndBody() async throws {
|
||||
let session = MockURLSession(responses: [envelopeJSON("{}"), envelopeJSON("{}")])
|
||||
let api = MessageCenterAPI(client: APIClient(environment: .testing, session: session))
|
||||
|
||||
try await api.markAsRead(messageId: 21)
|
||||
try await api.delete(messageId: 21)
|
||||
|
||||
XCTAssertEqual(session.requests[0].httpMethod, "POST")
|
||||
XCTAssertEqual(session.requests[0].url?.path, "/api/app/msg/read")
|
||||
let readQuery = URLComponents(url: try XCTUnwrap(session.requests[0].url), resolvingAgainstBaseURL: false)?.queryItems
|
||||
XCTAssertEqual(readQuery?.first { $0.name == "id" }?.value, "21")
|
||||
|
||||
XCTAssertEqual(session.requests[1].httpMethod, "POST")
|
||||
XCTAssertEqual(session.requests[1].url?.path, "/api/app/msg/delete")
|
||||
let deleteBody = try bodyJSON(session.requests[1])
|
||||
XCTAssertEqual(deleteBody["id"] as? Int, 21)
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
171
suixinkanTests/MessageCenterViewModelTests.swift
Normal file
171
suixinkanTests/MessageCenterViewModelTests.swift
Normal file
@ -0,0 +1,171 @@
|
||||
//
|
||||
// MessageCenterViewModelTests.swift
|
||||
// suixinkanTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import XCTest
|
||||
@testable import suixinkan
|
||||
|
||||
/// 消息中心 ViewModel 测试。
|
||||
@MainActor
|
||||
final class MessageCenterViewModelTests: XCTestCase {
|
||||
func testInitialLoadAndLoadMoreUseLastIdUntilNoMore() async {
|
||||
let api = MockMessageCenterAPI()
|
||||
api.listResponses = [
|
||||
MessageListResponse(hasMore: true, lastId: 8, items: [makeMessage(id: 1), makeMessage(id: 8)]),
|
||||
MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 9)]),
|
||||
]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 1, api: api)
|
||||
await viewModel.loadMoreIfNeeded(lastVisibleIndex: 2, api: api)
|
||||
|
||||
XCTAssertEqual(api.listCalls.map(\.lastId), [0, 8])
|
||||
XCTAssertEqual(api.listCalls.map(\.limit), [20, 20])
|
||||
XCTAssertEqual(api.listCalls.map(\.unread), [0, 0])
|
||||
XCTAssertEqual(viewModel.items.map(\.id), [1, 8, 9])
|
||||
XCTAssertFalse(viewModel.canLoadMore)
|
||||
}
|
||||
|
||||
func testUnreadSelectionMarksReadAndOpensDetail() async {
|
||||
let api = MockMessageCenterAPI()
|
||||
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 3, isRead: false)])]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
var opened: MessageItem?
|
||||
viewModel.onOpenDetail = { opened = $0 }
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
await viewModel.selectMessage(id: 3, api: api)
|
||||
|
||||
XCTAssertEqual(api.readCalls, [3])
|
||||
XCTAssertEqual(opened?.id, 3)
|
||||
XCTAssertEqual(opened?.isRead, true)
|
||||
XCTAssertEqual(viewModel.items.first?.isRead, true)
|
||||
}
|
||||
|
||||
func testReadSelectionOpensDetailWithoutCallingReadAPI() async {
|
||||
let api = MockMessageCenterAPI()
|
||||
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 4, isRead: true)])]
|
||||
let viewModel = MessageCenterViewModel()
|
||||
var opened: MessageItem?
|
||||
viewModel.onOpenDetail = { opened = $0 }
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
await viewModel.selectMessage(id: 4, api: api)
|
||||
|
||||
XCTAssertTrue(api.readCalls.isEmpty)
|
||||
XCTAssertEqual(opened?.id, 4)
|
||||
}
|
||||
|
||||
func testUnreadSelectionFailureShowsMessageAndDoesNotOpenDetail() async {
|
||||
let api = MockMessageCenterAPI()
|
||||
api.listResponses = [MessageListResponse(hasMore: false, lastId: 0, items: [makeMessage(id: 5, isRead: false)])]
|
||||
api.readError = TestError(message: "fail")
|
||||
let viewModel = MessageCenterViewModel()
|
||||
var message: String?
|
||||
var didOpen = false
|
||||
viewModel.onShowMessage = { message = $0 }
|
||||
viewModel.onOpenDetail = { _ in didOpen = true }
|
||||
|
||||
await viewModel.loadInitial(api: api)
|
||||
await viewModel.selectMessage(id: 5, api: api)
|
||||
|
||||
XCTAssertEqual(message, "标记已读失败")
|
||||
XCTAssertFalse(didOpen)
|
||||
XCTAssertEqual(viewModel.items.first?.isRead, false)
|
||||
}
|
||||
|
||||
func testDeleteSuccessRemovesMessageAndReportsDeletedId() async {
|
||||
let api = MockMessageCenterAPI()
|
||||
let listViewModel = MessageCenterViewModel()
|
||||
api.listResponses = [MessageListResponse(items: [makeMessage(id: 7)])]
|
||||
await listViewModel.loadInitial(api: api)
|
||||
|
||||
let detailViewModel = MessageDetailViewModel(message: makeMessage(id: 7))
|
||||
var deletedId: Int?
|
||||
var message: String?
|
||||
detailViewModel.onDeleted = { deletedId = $0 }
|
||||
detailViewModel.onShowMessage = { message = $0 }
|
||||
|
||||
await detailViewModel.delete(api: api)
|
||||
listViewModel.removeMessage(id: deletedId ?? 0)
|
||||
|
||||
XCTAssertEqual(api.deleteCalls, [7])
|
||||
XCTAssertEqual(deletedId, 7)
|
||||
XCTAssertEqual(message, "删除成功")
|
||||
XCTAssertTrue(listViewModel.items.isEmpty)
|
||||
}
|
||||
|
||||
func testDeleteInvalidIdAndFailureMessages() async {
|
||||
let api = MockMessageCenterAPI()
|
||||
let invalidViewModel = MessageDetailViewModel(message: makeMessage(id: 0))
|
||||
var invalidMessage: String?
|
||||
invalidViewModel.onShowMessage = { invalidMessage = $0 }
|
||||
|
||||
await invalidViewModel.delete(api: api)
|
||||
|
||||
XCTAssertEqual(invalidMessage, "消息ID无效")
|
||||
XCTAssertTrue(api.deleteCalls.isEmpty)
|
||||
|
||||
api.deleteError = TestError(message: "server down")
|
||||
let failingViewModel = MessageDetailViewModel(message: makeMessage(id: 9))
|
||||
var failureMessage: String?
|
||||
failingViewModel.onShowMessage = { failureMessage = $0 }
|
||||
|
||||
await failingViewModel.delete(api: api)
|
||||
|
||||
XCTAssertEqual(failureMessage, "删除失败: server down")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class MockMessageCenterAPI: MessageCenterServing {
|
||||
var listResponses: [MessageListResponse] = []
|
||||
var listCalls: [(lastId: Int, limit: Int, unread: Int)] = []
|
||||
var readCalls: [Int] = []
|
||||
var deleteCalls: [Int] = []
|
||||
var readError: Error?
|
||||
var deleteError: Error?
|
||||
|
||||
func list(lastId: Int, limit: Int, unread: Int) async throws -> MessageListResponse {
|
||||
listCalls.append((lastId, limit, unread))
|
||||
guard !listResponses.isEmpty else { return MessageListResponse() }
|
||||
return listResponses.removeFirst()
|
||||
}
|
||||
|
||||
func markAsRead(messageId: Int) async throws {
|
||||
readCalls.append(messageId)
|
||||
if let readError { throw readError }
|
||||
}
|
||||
|
||||
func delete(messageId: Int) async throws {
|
||||
deleteCalls.append(messageId)
|
||||
if let deleteError { throw deleteError }
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestError: LocalizedError {
|
||||
let message: String
|
||||
var errorDescription: String? { message }
|
||||
}
|
||||
|
||||
private func makeMessage(id: Int, isRead: Bool = false) -> MessageItem {
|
||||
MessageItem(
|
||||
id: id,
|
||||
receiverId: 1,
|
||||
receiverType: "staff",
|
||||
type: 1,
|
||||
typeName: "系统通知",
|
||||
title: "系统通知",
|
||||
content: "消息内容",
|
||||
pushAt: "",
|
||||
isRead: isRead,
|
||||
readAt: "",
|
||||
pushChannel: 1,
|
||||
pushChannelName: "站内信",
|
||||
createdAt: "2026-07-09 10:00:00",
|
||||
updatedAt: "2026-07-09 10:00:00"
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user