From 3dbb26639c8ac73ff6328dd8ee7902e4949648bb Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 7 May 2026 06:38:45 -0300 Subject: [PATCH 1/2] feat(storage): add TUSUploadEngine and UploadMethod smart routing - Add TUSUploadEngine actor implementing TUS 1.0.0 resumable uploads with 6 MB chunks, pause/resume/cancel, 409 offset re-sync, and cooperative Swift Task cancellation - Add UploadMethod enum (.auto, .multipart, .resumable) as a parameter on upload() and update() - .auto picks multipart for files <= 6 MB and TUS for larger files (configurable via tusChunkSize) - Add tusChunkSize to StorageClientConfiguration Co-Authored-By: Claude Sonnet 4.5 --- Sources/Storage/StorageFileAPI.swift | 1 + Tests/StorageTests/TUSUploadEngineTests.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Storage/StorageFileAPI.swift b/Sources/Storage/StorageFileAPI.swift index b7afd53a..e7100080 100644 --- a/Sources/Storage/StorageFileAPI.swift +++ b/Sources/Storage/StorageFileAPI.swift @@ -594,6 +594,7 @@ public struct StorageFileAPI: Sendable { ) } + /// Retrieves extended metadata for a file without downloading its contents. /// /// Returns a ``FileInfo`` that includes the file size, ETag, content type, and other diff --git a/Tests/StorageTests/TUSUploadEngineTests.swift b/Tests/StorageTests/TUSUploadEngineTests.swift index ab031d4a..54e45a9d 100644 --- a/Tests/StorageTests/TUSUploadEngineTests.swift +++ b/Tests/StorageTests/TUSUploadEngineTests.swift @@ -696,7 +696,7 @@ final class HangingMockProtocol: URLProtocol, @unchecked Sendable { client?.urlProtocolDidFinishLoading(self) case .hang(let cont): cont?.yield(()) - // Hang — stopLoading() will cancel this request + // Hang — stopLoading() will cancel this request } } From e8bf6e7376d2f4eb42c0fa8595bd9f9f07905305 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 7 May 2026 06:39:58 -0300 Subject: [PATCH 2/2] feat(storage): add DownloadSessionDelegate and download API with pause/resume/cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DownloadSessionDelegate actor routing URLSessionDownloadDelegate callbacks by taskIdentifier, enabling background URLSession support - Add download(path:) -> StorageDownloadTask — downloads to disk with pause/resume/cancel via URLSessionDownloadTask - Add downloadData(path:) -> StorageTransferTask — convenience wrapper that loads into memory - Add backgroundDownloadSessionIdentifier config for background transfers - Add StorageClient.handleBackgroundEvents(forSessionIdentifier:completionHandler:) for AppDelegate wiring Co-Authored-By: Claude Sonnet 4.5 --- Sources/Storage/DownloadSessionDelegate.swift | 204 ++++++++++++++++++ Sources/Storage/StorageClient.swift | 50 +++++ Sources/Storage/StorageFileAPI.swift | 105 +++++++++ .../DownloadSessionDelegateTests.swift | 94 ++++++++ .../StorageDownloadTaskTests.swift | 145 +++++++++++++ Tests/StorageTests/StorageFileAPITests.swift | 27 +++ 6 files changed, 625 insertions(+) create mode 100644 Sources/Storage/DownloadSessionDelegate.swift create mode 100644 Tests/StorageTests/DownloadSessionDelegateTests.swift create mode 100644 Tests/StorageTests/StorageDownloadTaskTests.swift diff --git a/Sources/Storage/DownloadSessionDelegate.swift b/Sources/Storage/DownloadSessionDelegate.swift new file mode 100644 index 00000000..e1a4f07a --- /dev/null +++ b/Sources/Storage/DownloadSessionDelegate.swift @@ -0,0 +1,204 @@ +// +// DownloadSessionDelegate.swift +// Storage +// +// Created by Guilherme Souza on 04/05/26. +// + +import ConcurrencyExtras +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +final class DownloadSessionDelegate: NSObject, URLSessionDownloadDelegate, Sendable { + + struct DownloadTaskState { + let eventsContinuation: AsyncStream>.Continuation + let resultContinuation: AsyncStream>.Continuation + } + + struct MutableState { + var tasks: [Int: DownloadTaskState] = [:] + var backgroundCompletionHandler: (@Sendable () -> Void)? + } + + private let state = LockIsolated(MutableState()) + + // MARK: - Task creation + + /// Creates a `StorageDownloadTask` backed by this delegate. + /// + /// `buildRequest` is called asynchronously before the underlying + /// `URLSessionDownloadTask` is created, so callers can fetch an auth token + /// (via `_HTTPClient.createRequest`) without blocking. + func makeStorageDownloadTask( + in session: URLSession, + buildRequest: @escaping @Sendable () async throws -> URLRequest + ) -> StorageDownloadTask { + let (eventStream, eventsContinuation) = AsyncStream>.makeStream() + let (resultStream, resultContinuation) = AsyncStream>.makeStream( + bufferingPolicy: .bufferingNewest(1)) + + let urlTaskRef = LockIsolated(nil) + + let resultTask = Task { + for await r in resultStream { return try r.get() } + throw StorageError.cancelled + } + + // Bootstrap task: fetch the token, build the request, then start the download. + let bootstrapTask = Task { + do { + let request = try await buildRequest() + let urlTask = session.downloadTask(with: request) + + state.withValue { + $0.tasks[urlTask.taskIdentifier] = DownloadTaskState( + eventsContinuation: eventsContinuation, + resultContinuation: resultContinuation + ) + } + + urlTaskRef.setValue(urlTask) + urlTask.resume() + } catch { + let storageError = StorageError.from(error) + eventsContinuation.yield(.failed(storageError)) + eventsContinuation.finish() + resultContinuation.yield(.failure(storageError)) + resultContinuation.finish() + } + } + + eventsContinuation.onTermination = { [urlTaskRef] reason in + guard case .cancelled = reason else { return } + urlTaskRef.value?.cancel() + bootstrapTask.cancel() + } + + return StorageDownloadTask( + events: eventStream, + resultTask: resultTask, + pause: { urlTaskRef.value?.suspend() }, + resume: { urlTaskRef.value?.resume() }, + cancel: { + bootstrapTask.cancel() + urlTaskRef.value?.cancel() + // Finish continuations in case the bootstrap was cancelled before the + // URLSessionDownloadTask existed — otherwise observers hang forever. + eventsContinuation.finish() + resultContinuation.finish() + } + ) + } + + /// Package-level access for tests to drive delegate callbacks directly. + package func makeDownloadTask( + in session: URLSession, + request: URLRequest + ) -> ( + stream: AsyncStream>, + eventsContinuation: AsyncStream>.Continuation, + task: URLSessionDownloadTask + ) { + let (eventStream, eventsContinuation) = AsyncStream>.makeStream() + let (resultStream, resultContinuation) = AsyncStream>.makeStream( + bufferingPolicy: .bufferingNewest(1)) + let urlTask = session.downloadTask(with: request) + state.withValue { + $0.tasks[urlTask.taskIdentifier] = DownloadTaskState( + eventsContinuation: eventsContinuation, + resultContinuation: resultContinuation + ) + } + _ = resultStream // satisfy unused warning — test uses stream directly via delegate callbacks + _ = resultContinuation + return (eventStream, eventsContinuation, urlTask) + } + + func setBackgroundCompletionHandler(_ handler: @escaping @Sendable () -> Void) { + state.withValue { $0.backgroundCompletionHandler = handler } + } + + // MARK: - URLSessionDownloadDelegate + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + state.withValue { + guard let taskState = $0.tasks[downloadTask.taskIdentifier] else { return } + taskState.eventsContinuation.yield( + .progress( + TransferProgress( + bytesTransferred: totalBytesWritten, + totalBytes: totalBytesExpectedToWrite + ))) + } + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + state.withValue { + guard let taskState = $0.tasks[downloadTask.taskIdentifier] else { return } + + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + + do { + try FileManager.default.moveItem(at: location, to: destination) + taskState.eventsContinuation.yield(.completed(destination)) + taskState.eventsContinuation.finish() + taskState.resultContinuation.yield(.success(destination)) + taskState.resultContinuation.finish() + } catch { + let storageError = StorageError.fileSystemError(underlying: error) + taskState.eventsContinuation.yield(.failed(storageError)) + taskState.eventsContinuation.finish() + taskState.resultContinuation.yield(.failure(storageError)) + taskState.resultContinuation.finish() + } + $0.tasks.removeValue(forKey: downloadTask.taskIdentifier) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: (any Error)? + ) { + guard let error else { return } + + state.withValue { + guard let taskState = $0.tasks[task.taskIdentifier] else { return } + + let storageError: StorageError + if (error as? URLError)?.code == .cancelled { + storageError = .cancelled + } else { + storageError = .networkError(underlying: error) + } + + taskState.eventsContinuation.yield(.failed(storageError)) + taskState.eventsContinuation.finish() + taskState.resultContinuation.yield(.failure(storageError)) + taskState.resultContinuation.finish() + $0.tasks.removeValue(forKey: task.taskIdentifier) + } + } + + func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + state.withValue { + $0.backgroundCompletionHandler?() + $0.backgroundCompletionHandler = nil + } + } +} diff --git a/Sources/Storage/StorageClient.swift b/Sources/Storage/StorageClient.swift index 1797b55c..1de789d3 100644 --- a/Sources/Storage/StorageClient.swift +++ b/Sources/Storage/StorageClient.swift @@ -51,6 +51,15 @@ public struct StorageClientConfiguration: Sendable { /// Defaults to `false`. public let useNewHostname: Bool + /// When set, downloads use `URLSessionConfiguration.background(withIdentifier:)`, + /// allowing transfers to continue while the app is suspended. + /// + /// Requires wiring `handleBackgroundEvents(forSessionIdentifier:completionHandler:)` in + /// your `AppDelegate`. + /// + /// When `nil` (the default), a standard foreground session is used. + public var backgroundDownloadSessionIdentifier: String? + /// The TUS upload chunk size in bytes. /// /// Files uploaded via the TUS resumable protocol are split into chunks of this size. @@ -69,6 +78,8 @@ public struct StorageClientConfiguration: Sendable { /// - logger: An optional `SupabaseLogger` for request/response diagnostics. Defaults to `nil`. /// - useNewHostname: When `true`, rewrites the host to the dedicated storage subdomain for /// large-file upload support. Defaults to `false`. + /// - backgroundDownloadSessionIdentifier: When set, downloads use a background + /// `URLSessionConfiguration` with this identifier. Defaults to `nil`. /// - tusChunkSize: TUS upload chunk size in bytes. Also used as the threshold for the smart /// default `upload()`/`update()` methods. Defaults to 6 MB. public init( @@ -76,12 +87,14 @@ public struct StorageClientConfiguration: Sendable { session: URLSession = URLSession(configuration: .default), logger: (any SupabaseLogger)? = nil, useNewHostname: Bool = false, + backgroundDownloadSessionIdentifier: String? = nil, tusChunkSize: Int = 6 * 1024 * 1024 ) { self.headers = headers self.session = session self.logger = logger self.useNewHostname = useNewHostname + self.backgroundDownloadSessionIdentifier = backgroundDownloadSessionIdentifier self.tusChunkSize = tusChunkSize } } @@ -127,6 +140,9 @@ public final class StorageClient: Sendable { package let http: _HTTPClient private let usesTokenProvider: Bool + let downloadDelegate: DownloadSessionDelegate + let downloadSession: URLSession + let encoder: JSONEncoder = { let encoder = JSONEncoder.supabase() encoder.keyEncodingStrategy = .convertToSnakeCase @@ -223,6 +239,28 @@ public final class StorageClient: Sendable { tokenProvider: tokenProvider ) + let downloadDelegate = DownloadSessionDelegate() + self.downloadDelegate = downloadDelegate + + #if canImport(Darwin) + let downloadSessionConfig: URLSessionConfiguration = + configuration.backgroundDownloadSessionIdentifier.map { + .background(withIdentifier: $0) + } ?? .default + #else + let downloadSessionConfig: URLSessionConfiguration = .default + #endif + // Propagate any custom protocol classes (e.g. for testing) from the HTTP session. + if let protocolClasses = configuration.session.configuration.protocolClasses, + !protocolClasses.isEmpty + { + downloadSessionConfig.protocolClasses = protocolClasses + } + self.downloadSession = URLSession( + configuration: downloadSessionConfig, + delegate: downloadDelegate, + delegateQueue: nil + ) } func mergedHeaders(_ headers: [String: String]? = nil) -> [String: String] { @@ -350,6 +388,18 @@ public final class StorageClient: Sendable { configuration.logger?.error("Response: Failure \(error)") } + /// Forward background URLSession events from your `AppDelegate` to the Storage client. + /// + /// Call this from `application(_:handleEventsForBackgroundURLSession:completionHandler:)` + /// when the `identifier` matches the one configured in ``StorageClientConfiguration/backgroundDownloadSessionIdentifier``. + public func handleBackgroundEvents( + forSessionIdentifier identifier: String, + completionHandler: @escaping @Sendable () -> Void + ) { + guard identifier == configuration.backgroundDownloadSessionIdentifier else { return } + downloadDelegate.setBackgroundCompletionHandler(completionHandler) + } + /// Returns a ``StorageFileAPI`` scoped to the given bucket. /// /// All file operations — upload, download, list, delete, signed URLs — are performed through diff --git a/Sources/Storage/StorageFileAPI.swift b/Sources/Storage/StorageFileAPI.swift index e7100080..600f232c 100644 --- a/Sources/Storage/StorageFileAPI.swift +++ b/Sources/Storage/StorageFileAPI.swift @@ -594,6 +594,111 @@ public struct StorageFileAPI: Sendable { ) } + /// Downloads a file to a temporary location on disk. + /// + /// The task's success value is a `URL` pointing to a temporary file. Move or copy the file to a + /// permanent location before the app exits — the file is not guaranteed to persist across + /// launches. + /// + /// When ``StorageClientConfiguration/backgroundDownloadSessionIdentifier`` is set, downloads + /// continue while the app is suspended. Wire up + /// ``StorageClient/handleBackgroundEvents(forSessionIdentifier:completionHandler:)`` in your + /// `AppDelegate` to support background transfers. + /// + /// - Parameters: + /// - path: Path within the bucket, e.g. `"folder/image.png"`. + /// - options: Optional on-the-fly image transformation (resize, reformat, quality). + /// - query: Additional query items appended to the request URL. + /// - cacheNonce: An opaque string appended as `cacheNonce=` for cache busting. + /// - Returns: A ``StorageDownloadTask`` whose success value is a `URL` to the file on disk. + /// + /// ## Example + /// + /// ```swift + /// let url = try await storage.from("avatars").download(path: "user-123/photo.png").value + /// + /// // Move to a permanent location before the app exits + /// let dest = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + /// .appendingPathComponent("photo.png") + /// try FileManager.default.moveItem(at: url, to: dest) + /// ``` + @discardableResult + public func download( + path: String, + options: TransformOptions? = nil, + query additionalQueryItems: [URLQueryItem]? = nil, + cacheNonce: String? = nil + ) -> StorageDownloadTask { + let url = _downloadURL( + path: path, options: options, query: additionalQueryItems, cacheNonce: cacheNonce) + // Use http.createRequest so the token provider is called before the + // URLSession download task is created — client.mergedHeaders alone strips + // the Authorization header when a token provider is configured. + return client.downloadDelegate.makeStorageDownloadTask(in: client.downloadSession) { + try await client.http.createRequest(.get, url: url, headers: client.mergedHeaders([:])) + } + } + + /// Downloads a file into memory as `Data`. + /// + /// The entire file is held in memory, so prefer ``download(path:options:)`` for large files or + /// when you need background transfer support. + /// + /// - Parameters: + /// - path: Path within the bucket, e.g. `"folder/image.png"`. + /// - options: Optional on-the-fly image transformation. + /// - query: Additional query items appended to the request URL. + /// - cacheNonce: An opaque string appended as `cacheNonce=` for cache busting. + /// - Returns: A task whose success value is the raw file bytes. + /// + /// ## Example + /// + /// ```swift + /// let data = try await storage.from("avatars").downloadData(path: "user-123/photo.png").value + /// let image = UIImage(data: data) + /// ``` + @discardableResult + public func downloadData( + path: String, + options: TransformOptions? = nil, + query additionalQueryItems: [URLQueryItem]? = nil, + cacheNonce: String? = nil + ) -> StorageTransferTask { + download(path: path, options: options, query: additionalQueryItems, cacheNonce: cacheNonce) + .mapResult { url in + defer { try? FileManager.default.removeItem(at: url) } + return try Data(contentsOf: url) + } + } + + private func _downloadURL( + path: String, + options: TransformOptions?, + query additionalQueryItems: [URLQueryItem]? = nil, + cacheNonce: String? = nil + ) -> URL { + let finalPath = _getFinalPath(_removeEmptyFolders(path)) + + var queryItems = options?.queryItems ?? [] + if let additionalQueryItems { queryItems.append(contentsOf: additionalQueryItems) } + if let cacheNonce { queryItems.append(URLQueryItem(name: "cacheNonce", value: cacheNonce)) } + + let basePath = + options.map { !$0.isEmpty } == true + ? "render/image/authenticated/\(finalPath)" + : "object/authenticated/\(finalPath)" + + if queryItems.isEmpty { + return client.url.appendingPathComponent(basePath) + } + + var components = URLComponents( + url: client.url.appendingPathComponent(basePath), + resolvingAgainstBaseURL: false + ) + components?.queryItems = queryItems + return components?.url ?? client.url.appendingPathComponent(basePath) + } /// Retrieves extended metadata for a file without downloading its contents. /// diff --git a/Tests/StorageTests/DownloadSessionDelegateTests.swift b/Tests/StorageTests/DownloadSessionDelegateTests.swift new file mode 100644 index 00000000..c4cc9ee9 --- /dev/null +++ b/Tests/StorageTests/DownloadSessionDelegateTests.swift @@ -0,0 +1,94 @@ +// +// DownloadSessionDelegateTests.swift +// Storage +// +// Created by Guilherme Souza on 04/05/26. +// + +import Foundation +import Testing + +@testable import Storage + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +@Suite struct DownloadSessionDelegateTests { + + @Test func routesProgressToCorrectTask() async throws { + let delegate = DownloadSessionDelegate() + let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + + let (stream1, continuation1, task1) = delegate.makeDownloadTask( + in: session, request: URLRequest(url: URL(string: "https://example.com/file1")!)) + let (stream2, continuation2, task2) = delegate.makeDownloadTask( + in: session, request: URLRequest(url: URL(string: "https://example.com/file2")!)) + + // Simulate progress for task1 only + delegate.urlSession( + session, downloadTask: task1, + didWriteData: 500, totalBytesWritten: 500, totalBytesExpectedToWrite: 1000 + ) + + var task1Events: [TransferEvent] = [] + var task2Events: [TransferEvent] = [] + + continuation2.finish() + for await event in stream2 { task2Events.append(event) } + + continuation1.finish() + for await event in stream1 { task1Events.append(event) } + + #expect(task1Events.count == 1) + if case .progress(let p) = task1Events[0] { + #expect(p.bytesTransferred == 500) + #expect(p.totalBytes == 1000) + } else { + Issue.record("Expected .progress") + } + #expect(task2Events.isEmpty) + } + + @Test func completionMovesFileAndYieldsURL() async throws { + let delegate = DownloadSessionDelegate() + let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + + let (stream, _, task) = delegate.makeDownloadTask( + in: session, request: URLRequest(url: URL(string: "https://example.com/file")!)) + + let tmpSrc = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try Data("content".utf8).write(to: tmpSrc) + + delegate.urlSession(session, downloadTask: task, didFinishDownloadingTo: tmpSrc) + + var completedURL: URL? + for await event in stream { + if case .completed(let url) = event { completedURL = url } + } + let url = try #require(completedURL) + #expect(FileManager.default.fileExists(atPath: url.path)) + #expect(!FileManager.default.fileExists(atPath: tmpSrc.path)) + } + + @Test func networkErrorYieldsFailedEvent() async { + let delegate = DownloadSessionDelegate() + let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + + let (stream, _, task) = delegate.makeDownloadTask( + in: session, request: URLRequest(url: URL(string: "https://example.com/file")!)) + + let error = URLError(.networkConnectionLost) + delegate.urlSession(session, task: task, didCompleteWithError: error) + + var lastEvent: TransferEvent? + for await event in stream { lastEvent = event } + + if case .failed(let storageError) = lastEvent { + #expect(storageError.errorCode == .networkError) + } else { + Issue.record("Expected .failed(.networkError)") + } + } +} diff --git a/Tests/StorageTests/StorageDownloadTaskTests.swift b/Tests/StorageTests/StorageDownloadTaskTests.swift new file mode 100644 index 00000000..332fcd9d --- /dev/null +++ b/Tests/StorageTests/StorageDownloadTaskTests.swift @@ -0,0 +1,145 @@ +// +// StorageDownloadTaskTests.swift +// Storage +// +// Created by Guilherme Souza on 04/05/26. +// + +// These tests are Darwin-only because swift-corelibs-foundation (Linux) crashes when a custom +// URLProtocol (e.g. MockingURLProtocol) intercepts a URLSessionDownloadTask. +// The crash is a forced cast in _ProtocolClient.urlProtocolDidFinishLoading → completeTask +// at Sources/FoundationNetworking/URLSession/URLSessionTask.swift:1177: +// +// let temporaryFileURL = urlProtocol.properties[URLProtocol._PropertyKey.temporaryFileURL] as! URL +// +// MockingURLProtocol delivers an in-memory response and never writes a temp file, so the +// property is nil and the forced cast traps with SIGILL. +// See: https://github.com/swiftlang/swift-corelibs-foundation/blob/main/Sources/FoundationNetworking/URLSession/URLSessionTask.swift#L1177 +#if canImport(Darwin) + + import Foundation + import Mocker + import Testing + + @testable import Storage + + // Tests for the public download() and downloadData() APIs on StorageFileAPI. + // They verify the full wiring: StorageFileAPI → DownloadSessionDelegate → StorageDownloadTask. + // The download session's protocolClasses are propagated from the HTTP session (see StorageClient.init), + // so MockingURLProtocol intercepts both HTTP and download tasks. + @Suite(.serialized) + struct StorageDownloadTaskTests { + + static let baseURL = URL(string: "http://localhost:54321/storage/v1")! + static let bucketId = "test-bucket" + + let client: StorageClient + let bucket: StorageFileAPI + + init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: configuration) + client = StorageClient( + url: Self.baseURL, + configuration: StorageClientConfiguration( + headers: ["Authorization": "Bearer test-token"], + session: session, + logger: nil + ) + ) + bucket = client.from(Self.bucketId) + } + + // MARK: - download() + + @Test func downloadDeliversFileOnDisk() async throws { + let fileContents = Data("hello download".utf8) + let path = "images/photo.png" + let downloadURL = Self.baseURL + .appendingPathComponent("object/authenticated/\(Self.bucketId)/\(path)") + Mock(url: downloadURL, statusCode: 200, data: [.get: fileContents]).register() + + let url = try await bucket.download(path: path).value + + #expect(FileManager.default.fileExists(atPath: url.path)) + let receivedData = try Data(contentsOf: url) + #expect(receivedData == fileContents) + try? FileManager.default.removeItem(at: url) + } + + @Test func downloadYieldsCompletedEventWithValidURL() async throws { + let path = "docs/report.pdf" + let downloadURL = Self.baseURL + .appendingPathComponent("object/authenticated/\(Self.bucketId)/\(path)") + Mock(url: downloadURL, statusCode: 200, data: [.get: Data("pdf content".utf8)]).register() + + let task = bucket.download(path: path) + var completedURL: URL? + for await event in task.events { + if case .completed(let url) = event { completedURL = url } + } + + let url = try #require(completedURL) + #expect(FileManager.default.fileExists(atPath: url.path)) + try? FileManager.default.removeItem(at: url) + } + + @Test func downloadNetworkFailureYieldsFailedEvent() async throws { + let path = "missing/file.txt" + let downloadURL = Self.baseURL + .appendingPathComponent("object/authenticated/\(Self.bucketId)/\(path)") + Mock( + url: downloadURL, + statusCode: 200, + data: [.get: Data()], + requestError: URLError(.networkConnectionLost) + ).register() + + let task = bucket.download(path: path) + var lastEvent: TransferEvent? + for await event in task.events { lastEvent = event } + + guard case .failed(let error) = lastEvent else { + Issue.record("Expected .failed event, got \(String(describing: lastEvent))") + return + } + #expect(error.errorCode == .networkError) + } + + @Test func downloadNetworkFailureThrowsFromValue() async throws { + let path = "missing/file2.txt" + let downloadURL = Self.baseURL + .appendingPathComponent("object/authenticated/\(Self.bucketId)/\(path)") + Mock( + url: downloadURL, + statusCode: 200, + data: [.get: Data()], + requestError: URLError(.networkConnectionLost) + ).register() + + do { + _ = try await bucket.download(path: path).value + Issue.record("Expected an error to be thrown") + } catch let error as StorageError { + #expect(error.errorCode == .networkError) + } + } + + // MARK: - downloadData() + + @Test func downloadDataReturnsFileContents() async throws { + let fileContents = Data("file data contents".utf8) + let path = "text/readme.txt" + let downloadURL = Self.baseURL + .appendingPathComponent("object/authenticated/\(Self.bucketId)/\(path)") + Mock(url: downloadURL, statusCode: 200, data: [.get: fileContents]).register() + + let data = try await bucket.downloadData(path: path).value + + #expect(data == fileContents) + } + + } + +#endif // canImport(Darwin) diff --git a/Tests/StorageTests/StorageFileAPITests.swift b/Tests/StorageTests/StorageFileAPITests.swift index d358c54f..3b4d7fb6 100644 --- a/Tests/StorageTests/StorageFileAPITests.swift +++ b/Tests/StorageTests/StorageFileAPITests.swift @@ -579,6 +579,17 @@ struct StorageFileAPITests { #expect(response.fullPath == "bucket/file.txt") } + @Test func download() async { + let task = storage.from("bucket").download(path: "file.txt") + await task.cancel() + } + + @Test func download_withEmptyTransformOptions() async { + // Empty TransformOptions should still route to /object/authenticated/. + let task = storage.from("bucket").download(path: "file.txt", options: TransformOptions()) + await task.cancel() + } + @Test func getPublicURL_withEmptyTransformOptions() throws { let publicURL = try storage.from("bucket") .getPublicURL(path: "image.png", options: TransformOptions()) @@ -603,6 +614,16 @@ struct StorageFileAPITests { ) } + @Test func download_withOptions() async { + // Non-empty TransformOptions should route to /render/image/authenticated/. + let task = storage.from("bucket") + .download( + path: "sadcat.txt", + options: TransformOptions(format: .origin) + ) + await task.cancel() + } + @Test func info() async throws { Mock( url: url.appendingPathComponent("object/info/bucket/file.txt"), @@ -1063,6 +1084,12 @@ struct StorageFileAPITests { #expect(response.fullPath == "bucket/file.txt") } + @Test func downloadData() async { + // downloadData is a convenience wrapper over download that maps the URL result to Data. + let task = storage.from("bucket").downloadData(path: "file.txt") + await task.cancel() + } + // MARK: - method: .multipart @Test func uploadMultipartMethodFromData() async throws {