// // 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) } func testUploadPauseThenResumeCompletes() async throws { let uploader = PausingThenSucceedCloudUploader(url: "https://cdn/resumed.jpg") let api = TransferMockCloudDriveAPI() let manager = CloudTransferManager( store: MemoryTransferStore(), uploader: uploader, api: api, downloader: FakeDownloader(), photoSaver: FakePhotoSaver(), scenicIdProvider: { 10 } ) let fileURL = try makeTempFile(name: "resume.jpg") manager.addUploadTask(localFileURL: fileURL, fileName: "resume.jpg", fileType: 2, fileSize: 3, parentFolderId: 7) let id = try XCTUnwrap(manager.uploadTasks.first?.id) try await waitUntil { uploader.attempts == 1 } manager.pauseUpload(id: id) try await waitUntil { manager.uploadTasks.first?.status == .paused } manager.resumeUpload(id: id) try await waitUntil { manager.uploadTasks.isEmpty } XCTAssertEqual(uploader.attempts, 2) XCTAssertEqual(api.uploadCalls.first?.parentFolderId, 7) XCTAssertEqual(api.uploadCalls.first?.fileUrl, "https://cdn/resumed.jpg") } func testFailedUploadCanRetry() async throws { let uploader = FailOnceCloudUploader(url: "https://cdn/retried.jpg") let api = TransferMockCloudDriveAPI() let manager = CloudTransferManager( store: MemoryTransferStore(), uploader: uploader, api: api, downloader: FakeDownloader(), photoSaver: FakePhotoSaver(), scenicIdProvider: { 10 } ) let fileURL = try makeTempFile(name: "retry.jpg") manager.addUploadTask(localFileURL: fileURL, fileName: "retry.jpg", fileType: 2, fileSize: 3, parentFolderId: 0) try await waitUntil { manager.uploadTasks.first?.status == .failed } let id = try XCTUnwrap(manager.uploadTasks.first?.id) manager.resumeUpload(id: id) try await waitUntil { manager.uploadTasks.isEmpty } XCTAssertEqual(uploader.attempts, 2) XCTAssertEqual(api.uploadCalls.first?.fileUrl, "https://cdn/retried.jpg") } func testUploadCancelRemovesTaskAndStagedFile() async throws { let uploader = PausingThenSucceedCloudUploader(url: "unused") let manager = CloudTransferManager( store: MemoryTransferStore(), uploader: uploader, api: TransferMockCloudDriveAPI(), downloader: FakeDownloader(), photoSaver: FakePhotoSaver(), scenicIdProvider: { 10 } ) let fileURL = try makeTempFile(name: "cancel.jpg") manager.addUploadTask(localFileURL: fileURL, fileName: "cancel.jpg", fileType: 2, fileSize: 3, parentFolderId: 0) let id = try XCTUnwrap(manager.uploadTasks.first?.id) manager.cancelUpload(id: id) XCTAssertTrue(manager.uploadTasks.isEmpty) XCTAssertFalse(FileManager.default.fileExists(atPath: fileURL.path)) } func testDownloadPauseThenResumeCompletes() async throws { let downloader = PausingThenSucceedDownloader() 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/resume.jpg", fileName: "resume.jpg", fileType: 2) let id = try XCTUnwrap(manager.downloadTasks.first?.id) try await waitUntil { await downloader.attemptCount() == 1 } manager.pauseDownload(id: id) try await waitUntil { manager.downloadTasks.first?.status == .paused } manager.resumeDownload(id: id) try await waitUntil { manager.downloadTasks.first?.status == .completed } let attemptCount = await downloader.attemptCount() XCTAssertEqual(attemptCount, 2) XCTAssertEqual(saver.saved.count, 1) XCTAssertEqual(manager.downloadTasks.first?.progress, 100) } 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 func waitUntil( timeout: TimeInterval = 2, condition: @escaping () async -> Bool ) async throws { let deadline = Date().addingTimeInterval(timeout) while !(await 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 } } @MainActor private final class PausingThenSucceedCloudUploader: CloudDriveUploading { let url: String private(set) var attempts = 0 init(url: String) { self.url = url } func uploadCloudDriveFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { attempts += 1 if attempts == 1 { try await Task.sleep(nanoseconds: 5_000_000_000) } onProgress(100) return url } } @MainActor private final class FailOnceCloudUploader: CloudDriveUploading { let url: String private(set) var attempts = 0 init(url: String) { self.url = url } func uploadCloudDriveFile(data: Data, fileName: String, fileType: Int, scenicId: Int, onProgress: @escaping (Int) -> Void) async throws -> String { attempts += 1 if attempts == 1 { throw TestError.failed } 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 PausingThenSucceedDownloader: CloudFileDownloading { private let counter = DownloadAttemptCounter() func downloadFile(remoteURL: String, fileName: String, onProgress: @escaping @Sendable (Int) -> Void) async throws -> URL { let attempt = await counter.next() if attempt == 1 { try await Task.sleep(nanoseconds: 5_000_000_000) } onProgress(100) let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + "_" + fileName) try Data([1]).write(to: url) return url } func attemptCount() async -> Int { await counter.current() } } private actor DownloadAttemptCounter { private var value = 0 func next() -> Int { value += 1 return value } func current() -> Int { value } } 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" } }