From 69d05eff5dbb413b8b2a5ba565f7f5e19a6e0ab6 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Thu, 9 May 2024 10:43:22 -0300 Subject: [PATCH] test(storage): integration tests (#371) feat(storage): move objects between buckets feat(storage): copy objects between buckets --- .env.example | 1 + .github/workflows/integration-tests.yml | 2 +- Makefile | 1 + Package.swift | 1 + Sources/PostgREST/Types.swift | 2 +- Sources/Storage/BucketOptions.swift | 4 +- Sources/Storage/Deprecated.swift | 36 ++ Sources/Storage/StorageBucketApi.swift | 2 +- Sources/Storage/StorageFileApi.swift | 80 ++-- Sources/Storage/TransformOptions.swift | 4 +- Sources/Storage/Types.swift | 27 ++ .../FunctionsTests/FunctionsClientTests.swift | 2 +- .../Fixtures/Upload/file-2.txt | 1 + .../Fixtures/Upload/sadcat.jpg | Bin 0 -> 29526 bytes .../StorageClientIntegrationTests.swift | 68 ++++ .../StorageFileIntegrationTests.swift | 344 ++++++++++++++++++ .../StorageClientIntegrationTests.swift | 168 --------- Tests/StorageTests/SupabaseStorageTests.swift | 2 +- 18 files changed, 546 insertions(+), 199 deletions(-) create mode 100644 Tests/IntegrationTests/Fixtures/Upload/file-2.txt create mode 100644 Tests/IntegrationTests/Fixtures/Upload/sadcat.jpg create mode 100644 Tests/IntegrationTests/StorageClientIntegrationTests.swift create mode 100644 Tests/IntegrationTests/StorageFileIntegrationTests.swift delete mode 100644 Tests/StorageTests/StorageClientIntegrationTests.swift diff --git a/.env.example b/.env.example index 13a1fa5e..d95f4f19 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ SUPABASE_URL=https://mysupabasereference.supabase.co SUPABASE_ANON_KEY=my.supabase.anon.key +SUPABASE_SERVICE_ROLE_KEY=my.supabase.service.role.key \ No newline at end of file diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index a8ee1893..618bb356 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -26,4 +26,4 @@ jobs: env: SUPABASE_URL: ${{ secrets.SUPABASE_URL }} SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} - SUPABASE_SERVICE_KEY: ${{ secrets.SUPABASE_SERVICE_KEY }} + SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} diff --git a/Makefile b/Makefile index ebeb0613..2f0c535e 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,7 @@ define SECRETS enum DotEnv { static let SUPABASE_URL = "$(SUPABASE_URL)" static let SUPABASE_ANON_KEY = "$(SUPABASE_ANON_KEY)" + static let SUPABASE_SERVICE_ROLE_KEY = "$(SUPABASE_SERVICE_ROLE_KEY)" } endef diff --git a/Package.swift b/Package.swift index 2832c6f5..c9e8ca6d 100644 --- a/Package.swift +++ b/Package.swift @@ -84,6 +84,7 @@ let package = Package( "TestHelpers", "PostgREST", "Realtime", + "Storage", ] ), .target( diff --git a/Sources/PostgREST/Types.swift b/Sources/PostgREST/Types.swift index 818f9f72..b2c37888 100644 --- a/Sources/PostgREST/Types.swift +++ b/Sources/PostgREST/Types.swift @@ -32,7 +32,7 @@ public struct PostgrestResponse: Sendable { self.count = count self.value = value } - + /// Returns the response converting the returned Data into Unicode characters using a given encoding. public func string(encoding: String.Encoding = .utf8) -> String? { String(data: data, encoding: encoding) diff --git a/Sources/Storage/BucketOptions.swift b/Sources/Storage/BucketOptions.swift index aac5cc52..e11e59ef 100644 --- a/Sources/Storage/BucketOptions.swift +++ b/Sources/Storage/BucketOptions.swift @@ -2,10 +2,10 @@ import Foundation public struct BucketOptions: Sendable { public let `public`: Bool - public let fileSizeLimit: Int? + public let fileSizeLimit: String? public let allowedMimeTypes: [String]? - public init(public: Bool = false, fileSizeLimit: Int? = nil, allowedMimeTypes: [String]? = nil) { + public init(public: Bool = false, fileSizeLimit: String? = nil, allowedMimeTypes: [String]? = nil) { self.public = `public` self.fileSizeLimit = fileSizeLimit self.allowedMimeTypes = allowedMimeTypes diff --git a/Sources/Storage/Deprecated.swift b/Sources/Storage/Deprecated.swift index c4ffccc6..d5ec8d5d 100644 --- a/Sources/Storage/Deprecated.swift +++ b/Sources/Storage/Deprecated.swift @@ -30,3 +30,39 @@ extension StorageClientConfiguration { ) } } + +extension StorageFileApi { + @_disfavoredOverload + @available(*, deprecated, message: "Please use method that returns FileUploadResponse.") + @discardableResult + public func upload( + path: String, + file: Data, + options: FileOptions = FileOptions() + ) async throws -> String { + try await upload(path: path, file: file, options: options).fullPath + } + + @_disfavoredOverload + @available(*, deprecated, message: "Please use method that returns FileUploadResponse.") + @discardableResult + public func update( + path: String, + file: Data, + options: FileOptions = FileOptions() + ) async throws -> String { + try await update(path: path, file: file, options: options).fullPath + } + + @_disfavoredOverload + @available(*, deprecated, message: "Please use method that returns FileUploadResponse.") + @discardableResult + public func uploadToSignedURL( + path: String, + token: String, + file: Data, + options: FileOptions = FileOptions() + ) async throws -> String { + try await uploadToSignedURL(path: path, token: token, file: file, options: options).fullPath + } +} diff --git a/Sources/Storage/StorageBucketApi.swift b/Sources/Storage/StorageBucketApi.swift index 9ae3ae21..c99f5141 100644 --- a/Sources/Storage/StorageBucketApi.swift +++ b/Sources/Storage/StorageBucketApi.swift @@ -35,7 +35,7 @@ public class StorageBucketApi: StorageApi { var id: String var name: String var `public`: Bool - var fileSizeLimit: Int? + var fileSizeLimit: String? var allowedMimeTypes: [String]? } diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index 97f31276..81a94d0e 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -24,10 +24,6 @@ public class StorageFileApi: StorageApi { super.init(configuration: configuration) } - private struct UploadResponse: Decodable { - let Key: String - } - private struct MoveResponse: Decodable { let message: String } @@ -41,7 +37,7 @@ public class StorageFileApi: StorageApi { path: String, file: Data, options: FileOptions - ) async throws -> String { + ) async throws -> FileUploadResponse { let contentType = options.contentType var headers = HTTPHeaders([ "x-upsert": "\(options.upsert)", @@ -56,7 +52,12 @@ public class StorageFileApi: StorageApi { file: File(name: fileName, data: file, fileName: fileName, contentType: contentType) ) - return try await execute( + struct UploadResponse: Decodable { + let Key: String + let Id: String + } + + let response = try await execute( HTTPRequest( url: configuration.url.appendingPathComponent("object/\(bucketId)/\(path)"), method: method, @@ -66,7 +67,13 @@ public class StorageFileApi: StorageApi { headers: headers ) ) - .decoded(as: UploadResponse.self, decoder: configuration.decoder).Key + .decoded(as: UploadResponse.self, decoder: configuration.decoder) + + return FileUploadResponse( + id: response.Id, + path: path, + fullPath: response.Key + ) } /// Uploads a file to an existing bucket. @@ -80,7 +87,7 @@ public class StorageFileApi: StorageApi { path: String, file: Data, options: FileOptions = FileOptions() - ) async throws -> String { + ) async throws -> FileUploadResponse { try await uploadOrUpdate(method: .post, path: path, file: file, options: options) } @@ -95,16 +102,20 @@ public class StorageFileApi: StorageApi { path: String, file: Data, options: FileOptions = FileOptions() - ) async throws -> String { + ) async throws -> FileUploadResponse { try await uploadOrUpdate(method: .put, path: path, file: file, options: options) } /// Moves an existing file, optionally renaming it at the same time. /// - Parameters: - /// - from: The original file path, including the current file name. For example - /// `folder/image.png`. - /// - to: The new file path, including the new file name. For example `folder/image-copy.png`. - public func move(from source: String, to destination: String) async throws { + /// - source: The original file path, including the current file name. For example `folder/image.png`. + /// - destination: The new file path, including the new file name. For example `folder/image-copy.png`. + /// - options: The destination options. + public func move( + from source: String, + to destination: String, + options: DestinationOptions? = nil + ) async throws { try await execute( HTTPRequest( url: configuration.url.appendingPathComponent("object/move"), @@ -114,6 +125,7 @@ public class StorageFileApi: StorageApi { "bucketId": bucketId, "sourceKey": source, "destinationKey": destination, + "destinationBucket": options?.destinationBucket ] ) ) @@ -122,12 +134,20 @@ public class StorageFileApi: StorageApi { /// Copies an existing file to a new path in the same bucket. /// - Parameters: - /// - from: The original file path, including the current file name. For example - /// `folder/image.png`. - /// - to: The new file path, including the new file name. For example `folder/image-copy.png`. + /// - source: The original file path, including the current file name. For example `folder/image.png`. + /// - destination: The new file path, including the new file name. For example `folder/image-copy.png`. + /// - options: The destination options. @discardableResult - public func copy(from source: String, to destination: String) async throws -> String { - try await execute( + public func copy( + from source: String, + to destination: String, + options: DestinationOptions? = nil + ) async throws -> String { + struct UploadResponse: Decodable { + let Key: String + } + + return try await execute( HTTPRequest( url: configuration.url.appendingPathComponent("object/copy"), method: .post, @@ -136,6 +156,7 @@ public class StorageFileApi: StorageApi { "bucketId": bucketId, "sourceKey": source, "destinationKey": destination, + "destinationBucket": options?.destinationBucket ] ) ) @@ -393,15 +414,24 @@ public class StorageFileApi: StorageApi { /// /// - Note: Signed upload URLs can be used to upload files to the bucket without further /// authentication. They are valid for 2 hours. - public func createSignedUploadURL(path: String) async throws -> SignedUploadURL { + public func createSignedUploadURL( + path: String, + options: CreateSignedUploadURLOptions? = nil + ) async throws -> SignedUploadURL { struct Response: Decodable { let url: URL } + var headers = HTTPHeaders() + if let upsert = options?.upsert, upsert { + headers["x-upsert"] = "true" + } + let response = try await execute( HTTPRequest( url: configuration.url.appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"), - method: .post + method: .post, + headers: headers ) ) .decoded(as: Response.self, decoder: configuration.decoder) @@ -441,7 +471,7 @@ public class StorageFileApi: StorageApi { token: String, file: Data, options: FileOptions = FileOptions() - ) async throws -> String { + ) async throws -> SignedURLUploadResponse { let contentType = options.contentType var headers = HTTPHeaders([ "x-upsert": "\(options.upsert)", @@ -458,7 +488,11 @@ public class StorageFileApi: StorageApi { contentType: contentType )) - return try await execute( + struct UploadResponse: Decodable { + let Key: String + } + + let fullPath = try await execute( HTTPRequest( url: configuration.url .appendingPathComponent("object/upload/sign/\(bucketId)/\(path)"), @@ -471,6 +505,8 @@ public class StorageFileApi: StorageApi { ) .decoded(as: UploadResponse.self, decoder: configuration.decoder) .Key + + return SignedURLUploadResponse(path: path, fullPath: fullPath) } } diff --git a/Sources/Storage/TransformOptions.swift b/Sources/Storage/TransformOptions.swift index f5550b42..ff42d5dd 100644 --- a/Sources/Storage/TransformOptions.swift +++ b/Sources/Storage/TransformOptions.swift @@ -10,9 +10,9 @@ public struct TransformOptions: Encodable, Sendable { public init( width: Int? = nil, height: Int? = nil, - resize: String? = "cover", + resize: String? = nil, quality: Int? = 80, - format: String? = "origin" + format: String? = nil ) { self.width = width self.height = height diff --git a/Sources/Storage/Types.swift b/Sources/Storage/Types.swift index efc564ed..0e887210 100644 --- a/Sources/Storage/Types.swift +++ b/Sources/Storage/Types.swift @@ -92,3 +92,30 @@ public struct SignedUploadURL: Sendable { public let path: String public let token: String } + +public struct FileUploadResponse: Sendable { + public let id: String + public let path: String + public let fullPath: String +} + +public struct SignedURLUploadResponse: Sendable { + public let path: String + public let fullPath: String +} + +public struct CreateSignedUploadURLOptions: Sendable { + public var upsert: Bool + + public init(upsert: Bool) { + self.upsert = upsert + } +} + +public struct DestinationOptions: Sendable { + public var destinationBucket: String? + + public init(destinationBucket: String? = nil) { + self.destinationBucket = destinationBucket + } +} diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index 67bdbab9..fc060548 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -62,7 +62,7 @@ final class FunctionsClientTests: XCTestCase { let http = HTTPClientMock().any { _ in try .stub(body: Empty()) } let sut = FunctionsClient( - url: self.url, + url: url, headers: ["Apikey": apiKey], region: nil, http: http diff --git a/Tests/IntegrationTests/Fixtures/Upload/file-2.txt b/Tests/IntegrationTests/Fixtures/Upload/file-2.txt new file mode 100644 index 00000000..55ed72d4 --- /dev/null +++ b/Tests/IntegrationTests/Fixtures/Upload/file-2.txt @@ -0,0 +1 @@ +supabase txt file 2 diff --git a/Tests/IntegrationTests/Fixtures/Upload/sadcat.jpg b/Tests/IntegrationTests/Fixtures/Upload/sadcat.jpg new file mode 100644 index 0000000000000000000000000000000000000000..859aa4c33b257b76b63fcaf6b2eef57fc086096a GIT binary patch literal 29526 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 VBgiJ0|3oYtS5&0y!t&^k{U4%^@=O2# literal 0 HcmV?d00001 diff --git a/Tests/IntegrationTests/StorageClientIntegrationTests.swift b/Tests/IntegrationTests/StorageClientIntegrationTests.swift new file mode 100644 index 00000000..2d073eab --- /dev/null +++ b/Tests/IntegrationTests/StorageClientIntegrationTests.swift @@ -0,0 +1,68 @@ +// +// StorageClientIntegrationTests.swift +// +// +// Created by Guilherme Souza on 07/05/24. +// + +import InlineSnapshotTesting +import Storage +import XCTest + +final class StorageClientIntegrationTests: XCTestCase { + let storage = SupabaseStorageClient( + configuration: StorageClientConfiguration( + url: URL(string: "\(DotEnv.SUPABASE_URL)/storage/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_SERVICE_ROLE_KEY)", + ], + logger: nil + ) + ) + + func testBucket_CRUD() async throws { + let bucketName = "test-bucket" + + var buckets = try await storage.listBuckets() + XCTAssertFalse(buckets.contains(where: { $0.name == bucketName })) + + try await storage.createBucket(bucketName, options: .init(public: true)) + + var bucket = try await storage.getBucket(bucketName) + XCTAssertEqual(bucket.name, bucketName) + XCTAssertEqual(bucket.id, bucketName) + XCTAssertEqual(bucket.isPublic, true) + + buckets = try await storage.listBuckets() + XCTAssertTrue(buckets.contains { $0.id == bucket.id }) + + try await storage.updateBucket(bucketName, options: BucketOptions(allowedMimeTypes: ["image/jpeg"])) + + bucket = try await storage.getBucket(bucketName) + XCTAssertEqual(bucket.allowedMimeTypes, ["image/jpeg"]) + + try await storage.deleteBucket(bucketName) + + buckets = try await storage.listBuckets() + XCTAssertFalse(buckets.contains { $0.id == bucket.id }) + } + + func testGetBucketWithWrongId() async { + do { + _ = try await storage.getBucket("not-exist-id") + XCTFail("Unexpected success") + } catch { + assertInlineSnapshot(of: error, as: .dump) { + """ + ▿ StorageError + ▿ error: Optional + - some: "Bucket not found" + - message: "Bucket not found" + ▿ statusCode: Optional + - some: "404" + + """ + } + } + } +} diff --git a/Tests/IntegrationTests/StorageFileIntegrationTests.swift b/Tests/IntegrationTests/StorageFileIntegrationTests.swift new file mode 100644 index 00000000..735de890 --- /dev/null +++ b/Tests/IntegrationTests/StorageFileIntegrationTests.swift @@ -0,0 +1,344 @@ +// +// StorageFileIntegrationTests.swift +// +// +// Created by Guilherme Souza on 07/05/24. +// + +import InlineSnapshotTesting +import Storage +import XCTest + +final class StorageFileIntegrationTests: XCTestCase { + let storage = SupabaseStorageClient( + configuration: StorageClientConfiguration( + url: URL(string: "\(DotEnv.SUPABASE_URL)/storage/v1")!, + headers: [ + "Authorization": "Bearer \(DotEnv.SUPABASE_SERVICE_ROLE_KEY)", + ], + logger: nil + ) + ) + + var bucketName = "" + var file = Data() + var uploadPath = "" + + override func setUp() async throws { + try await super.setUp() + + bucketName = try await newBucket() + file = try Data(contentsOf: uploadFileURL("sadcat.jpg")) + uploadPath = "testpath/file-\(UUID().uuidString).jpg" + } + + override func tearDown() async throws { + try? await storage.emptyBucket(bucketName) + try? await storage.deleteBucket(bucketName) + + try await super.tearDown() + } + + func testGetPublicURL() throws { + let publicURL = try storage.from(bucketName).getPublicURL(path: uploadPath) + XCTAssertEqual( + publicURL.absoluteString, + "\(DotEnv.SUPABASE_URL)/storage/v1/object/public/\(bucketName)/\(uploadPath)" + ) + } + + func testGetPublicURLWithDownloadQueryString() throws { + let publicURL = try storage.from(bucketName).getPublicURL(path: uploadPath, download: true) + XCTAssertEqual( + publicURL.absoluteString, + "\(DotEnv.SUPABASE_URL)/storage/v1/object/public/\(bucketName)/\(uploadPath)?download=" + ) + } + + func testGetPublicURLWithCustomDownload() throws { + let publicURL = try storage.from(bucketName).getPublicURL(path: uploadPath, download: "test.jpg") + XCTAssertEqual( + publicURL.absoluteString, + "\(DotEnv.SUPABASE_URL)/storage/v1/object/public/\(bucketName)/\(uploadPath)?download=test.jpg" + ) + } + + func testSignURL() async throws { + _ = try await storage.from(bucketName).upload(path: uploadPath, file: file) + + let url = try await storage.from(bucketName).createSignedURL(path: uploadPath, expiresIn: 2000) + XCTAssertTrue( + url.absoluteString.contains("\(DotEnv.SUPABASE_URL)/storage/v1/object/sign/\(bucketName)/\(uploadPath)") + ) + } + + func testSignURL_withDownloadQueryString() async throws { + _ = try await storage.from(bucketName).upload(path: uploadPath, file: file) + + let url = try await storage.from(bucketName).createSignedURL(path: uploadPath, expiresIn: 2000, download: true) + XCTAssertTrue( + url.absoluteString.contains("\(DotEnv.SUPABASE_URL)/storage/v1/object/sign/\(bucketName)/\(uploadPath)") + ) + XCTAssertTrue(url.absoluteString.contains("&download=")) + } + + func testSignURL_withCustomFilenameForDownload() async throws { + _ = try await storage.from(bucketName).upload(path: uploadPath, file: file) + + let url = try await storage.from(bucketName).createSignedURL(path: uploadPath, expiresIn: 2000, download: "test.jpg") + XCTAssertTrue( + url.absoluteString.contains("\(DotEnv.SUPABASE_URL)/storage/v1/object/sign/\(bucketName)/\(uploadPath)") + ) + XCTAssertTrue(url.absoluteString.contains("&download=test.jpg")) + } + + func testUploadAndUpdateFile() async throws { + let file2 = try Data(contentsOf: uploadFileURL("file-2.txt")) + + try await storage.from(bucketName).upload(path: uploadPath, file: file) + + let res = try await storage.from(bucketName).update(path: uploadPath, file: file2) + XCTAssertEqual(res.path, uploadPath) + } + + func testUploadFileWithinFileSizeLimit() async throws { + bucketName = try await newBucket( + prefix: "with-limit", + options: BucketOptions(public: true, fileSizeLimit: "1mb") + ) + + try await storage.from(bucketName).upload(path: uploadPath, file: file) + } + + func testUploadFileThatExceedFileSizeLimit() async throws { + bucketName = try await newBucket( + prefix: "with-limit", + options: BucketOptions(public: true, fileSizeLimit: "1kb") + ) + + do { + try await storage.from(bucketName).upload(path: uploadPath, file: file) + XCTFail("Unexpected success") + } catch { + assertInlineSnapshot(of: error, as: .dump) { + """ + ▿ StorageError + ▿ error: Optional + - some: "Payload too large" + - message: "The object exceeded the maximum allowed size" + ▿ statusCode: Optional + - some: "413" + + """ + } + } + } + + func testUploadFileWithValidMimeType() async throws { + bucketName = try await newBucket( + prefix: "with-mimetype", + options: BucketOptions(public: true, allowedMimeTypes: ["image/jpeg"]) + ) + + try await storage.from(bucketName).upload( + path: uploadPath, + file: file, + options: FileOptions( + contentType: "image/jpeg" + ) + ) + } + + func testUploadFileWithInvalidMimeType() async throws { + bucketName = try await newBucket( + prefix: "with-mimetype", + options: BucketOptions(public: true, allowedMimeTypes: ["image/png"]) + ) + + do { + try await storage.from(bucketName).upload( + path: uploadPath, + file: file, + options: FileOptions( + contentType: "image/jpeg" + ) + ) + XCTFail("Unexpected success") + } catch { + assertInlineSnapshot(of: error, as: .dump) { + """ + ▿ StorageError + ▿ error: Optional + - some: "invalid_mime_type" + - message: "mime type image/jpeg is not supported" + ▿ statusCode: Optional + - some: "415" + + """ + } + } + } + + func testSignedURLForUpload() async throws { + let res = try await storage.from(bucketName).createSignedUploadURL(path: uploadPath) + XCTAssertEqual(res.path, uploadPath) + XCTAssertTrue( + res.signedURL.absoluteString.contains( + "\(DotEnv.SUPABASE_URL)/storage/v1/object/upload/sign/\(bucketName)/\(uploadPath)" + ) + ) + } + + func testCanUploadWithSignedURLForUpload() async throws { + let res = try await storage.from(bucketName).createSignedUploadURL(path: uploadPath) + + let uploadRes = try await storage.from(bucketName).uploadToSignedURL(path: res.path, token: res.token, file: file) + XCTAssertEqual(uploadRes.path, uploadPath) + } + + func testCanUploadOverwritingFilesWithSignedURL() async throws { + try await storage.from(bucketName).upload(path: uploadPath, file: file) + + let res = try await storage.from(bucketName).createSignedUploadURL(path: uploadPath, options: CreateSignedUploadURLOptions(upsert: true)) + let uploadRes = try await storage.from(bucketName).uploadToSignedURL(path: res.path, token: res.token, file: file) + XCTAssertEqual(uploadRes.path, uploadPath) + } + + func testCannotUploadToSignedURLTwice() async throws { + let res = try await storage.from(bucketName).createSignedUploadURL(path: uploadPath) + + try await storage.from(bucketName).uploadToSignedURL(path: res.path, token: res.token, file: file) + + do { + try await storage.from(bucketName).uploadToSignedURL(path: res.path, token: res.token, file: file) + XCTFail("Unexpected success") + } catch { + assertInlineSnapshot(of: error, as: .dump) { + """ + ▿ StorageError + ▿ error: Optional + - some: "Duplicate" + - message: "The resource already exists" + ▿ statusCode: Optional + - some: "409" + + """ + } + } + } + + func testListObjects() async throws { + try await storage.from(bucketName).upload(path: uploadPath, file: file) + let res = try await storage.from(bucketName).list(path: "testpath") + + XCTAssertEqual(res.count, 1) + XCTAssertEqual(res[0].name, uploadPath.replacingOccurrences(of: "testpath/", with: "")) + } + + func testMoveObjectToDifferentPath() async throws { + let newPath = "testpath/file-moved-\(UUID().uuidString).txt" + try await storage.from(bucketName).upload(path: uploadPath, file: file) + + try await storage.from(bucketName).move(from: uploadPath, to: newPath) + } + + func testMoveObjectsAcrossBucketsInDifferentPath() async throws { + let newBucketName = "bucket-move" + try await findOrCreateBucket(name: newBucketName) + + let newPath = "testpath/file-to-move-\(UUID().uuidString).txt" + try await storage.from(bucketName).upload(path: uploadPath, file: file) + + try await storage.from(bucketName).move( + from: uploadPath, + to: newPath, + options: DestinationOptions(destinationBucket: newBucketName) + ) + + _ = try await storage.from(newBucketName).download(path: newPath) + } + + func testCopyObjectToDifferentPath() async throws { + let newPath = "testpath/file-moved-\(UUID().uuidString).txt" + try await storage.from(bucketName).upload(path: uploadPath, file: file) + + try await storage.from(bucketName).copy(from: uploadPath, to: newPath) + } + + func testCopyObjectsAcrossBucketsInDifferentPath() async throws { + let newBucketName = "bucket-copy" + try await findOrCreateBucket(name: newBucketName) + + let newPath = "testpath/file-to-copy-\(UUID().uuidString).txt" + try await storage.from(bucketName).upload(path: uploadPath, file: file) + + try await storage.from(bucketName).copy( + from: uploadPath, + to: newPath, + options: DestinationOptions(destinationBucket: newBucketName) + ) + + _ = try await storage.from(newBucketName).download(path: newPath) + } + + func testDownloadsAnObject() async throws { + try await storage.from(bucketName).upload(path: uploadPath, file: file) + + let res = try await storage.from(bucketName).download(path: uploadPath) + XCTAssertGreaterThan(res.count, 0) + } + + func testRemovesAnObject() async throws { + try await storage.from(bucketName).upload(path: uploadPath, file: file) + + let res = try await storage.from(bucketName).remove(paths: [uploadPath]) + XCTAssertEqual(res.count, 1) + XCTAssertEqual(res[0].bucketId, bucketName) + XCTAssertEqual(res[0].name, uploadPath) + } + + func testGetPublishURLWithTransformationOptions() throws { + let res = try storage.from(bucketName).getPublicURL( + path: uploadPath, + options: TransformOptions( + width: 700, + height: 300, + quality: 70 + ) + ) + + XCTAssertEqual( + res.absoluteString, + "\(DotEnv.SUPABASE_URL)/storage/v1/render/image/public/\(bucketName)/\(uploadPath)?width=700&height=300&quality=70" + ) + } + + private func newBucket( + prefix: String = "", + options: BucketOptions = BucketOptions(public: true) + ) async throws -> String { + let bucketName = "\(!prefix.isEmpty ? prefix + "-" : "")bucket-\(UUID().uuidString)" + return try await findOrCreateBucket(name: bucketName, options: options) + } + + @discardableResult + private func findOrCreateBucket( + name: String, + options: BucketOptions = BucketOptions(public: true) + ) async throws -> String { + do { + _ = try await storage.getBucket(name) + } catch { + try await storage.createBucket(name, options: options) + } + + return name + } + + private func uploadFileURL(_ fileName: String) -> URL { + URL(fileURLWithPath: #file) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/Upload") + .appendingPathComponent(fileName) + } +} diff --git a/Tests/StorageTests/StorageClientIntegrationTests.swift b/Tests/StorageTests/StorageClientIntegrationTests.swift deleted file mode 100644 index 3f120190..00000000 --- a/Tests/StorageTests/StorageClientIntegrationTests.swift +++ /dev/null @@ -1,168 +0,0 @@ -// -// StorageClientIntegrationTests.swift -// -// -// Created by Guilherme Souza on 04/11/23. -// - -@testable import Storage -import XCTest - -final class StorageClientIntegrationTests: XCTestCase { - static var apiKey: String { - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" - } - - static var supabaseURL: String { - "http://localhost:54321/storage/v1" - } - - let bucketId = "tests" - - let storage = SupabaseStorageClient.test(supabaseURL: supabaseURL, apiKey: apiKey) - - let uploadData = try? Data( - contentsOf: URL( - string: "https://raw.githubusercontent.com/supabase-community/storage-swift/main/README.md" - )! - ) - - override func setUp() async throws { - try await super.setUp() - - try XCTSkipUnless( - ProcessInfo.processInfo.environment["INTEGRATION_TESTS"] != nil, - "INTEGRATION_TESTS not defined." - ) - - try? await storage.emptyBucket(bucketId) - try? await storage.deleteBucket(bucketId) - - try await storage.createBucket(bucketId, options: BucketOptions(public: true)) - } - - func testUpdateBucket() async throws { - var bucket = try await storage.getBucket(bucketId) - XCTAssertTrue(bucket.isPublic) - - try await storage.updateBucket(bucketId, options: BucketOptions(public: false)) - bucket = try await storage.getBucket(bucketId) - XCTAssertFalse(bucket.isPublic) - } - - func testListBuckets() async throws { - let buckets = try await storage.listBuckets() - XCTAssertTrue(buckets.contains { $0.id == bucketId }) - } - - func testFileIntegration() async throws { - var files = try await storage.from(bucketId).list() - XCTAssertTrue(files.isEmpty) - - try await uploadTestData() - - files = try await storage.from(bucketId).list() - XCTAssertEqual(files.map(\.name), ["README.md"]) - - let downloadedData = try await storage.from(bucketId).download(path: "README.md") - XCTAssertEqual(downloadedData, uploadData) - - try await storage.from(bucketId).move(from: "README.md", to: "README_2.md") - - var searchedFiles = try await storage.from(bucketId) - .list(options: .init(search: "README.md")) - XCTAssertTrue(searchedFiles.isEmpty) - - try await storage.from(bucketId).copy(from: "README_2.md", to: "README.md") - searchedFiles = try await storage.from(bucketId).list(options: .init(search: "README.md")) - XCTAssertEqual(searchedFiles.map(\.name), ["README.md"]) - - let removedFiles = try await storage.from(bucketId).remove(paths: ["README_2.md"]) - XCTAssertEqual(removedFiles.map(\.name), ["README_2.md"]) - } - - func testGetPublicURL() async throws { - try await uploadTestData() - - let path = "README.md" - - let baseUrl = try storage.from(bucketId).getPublicURL(path: path) - XCTAssertEqual(baseUrl.absoluteString, "\(Self.supabaseURL)/object/public/\(bucketId)/\(path)") - - let baseUrlWithDownload = try storage.from(bucketId).getPublicURL( - path: path, - download: true - ) - XCTAssertEqual( - baseUrlWithDownload.absoluteString, - "\(Self.supabaseURL)/object/public/\(bucketId)/\(path)?download=" - ) - - let baseUrlWithDownloadAndFileName = try storage.from(bucketId).getPublicURL( - path: path, download: "test" - ) - XCTAssertEqual( - baseUrlWithDownloadAndFileName.absoluteString, - "\(Self.supabaseURL)/object/public/\(bucketId)/\(path)?download=test" - ) - - let baseUrlWithAllOptions = try storage.from(bucketId).getPublicURL( - path: path, download: "test", - options: TransformOptions(width: 300, height: 300) - ) - XCTAssertEqual( - baseUrlWithAllOptions.absoluteString, - "\(Self.supabaseURL)/render/image/public/\(bucketId)/\(path)?download=test&width=300&height=300&resize=cover&quality=80&format=origin" - ) - } - - func testCreateSignedURL() async throws { - try await uploadTestData() - - let path = "README.md" - - let url = try await storage.from(bucketId).createSignedURL( - path: path, - expiresIn: 60, - download: "README_local.md" - ) - let components = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: true)) - - let downloadQuery = components.queryItems?.first(where: { $0.name == "download" }) - XCTAssertEqual(downloadQuery?.value, "README_local.md") - XCTAssertEqual(components.path, "/storage/v1/object/sign/\(bucketId)/\(path)") - } - - func testUpdate() async throws { - try await uploadTestData() - - let dataToUpdate = try? Data( - contentsOf: URL( - string: "https://raw.githubusercontent.com/supabase-community/supabase-swift/master/README.md" - )! - ) - - try await storage.from(bucketId).update( - path: "README.md", - file: dataToUpdate ?? Data() - ) - } - - func testCreateAndUploadToSignedUploadURL() async throws { - let path = "README-\(UUID().uuidString).md" - let url = try await storage.from(bucketId).createSignedUploadURL(path: path) - let key = try await storage.from(bucketId).uploadToSignedURL( - path: url.path, - token: url.token, - file: uploadData ?? Data() - ) - - XCTAssertEqual(key, "\(bucketId)/\(path)") - } - - private func uploadTestData() async throws { - _ = try await storage.from(bucketId).upload( - path: "README.md", file: uploadData ?? Data(), options: FileOptions(cacheControl: "3600") - ) - } -} diff --git a/Tests/StorageTests/SupabaseStorageTests.swift b/Tests/StorageTests/SupabaseStorageTests.swift index 22deab51..fb49d2aa 100644 --- a/Tests/StorageTests/SupabaseStorageTests.swift +++ b/Tests/StorageTests/SupabaseStorageTests.swift @@ -48,7 +48,7 @@ final class SupabaseStorageTests: XCTestCase { ) XCTAssertEqual( baseUrlWithAllOptions.absoluteString, - "\(supabaseURL)/render/image/public/\(bucketId)/\(path)?download=test&width=300&height=300&resize=cover&quality=80&format=origin" + "\(supabaseURL)/render/image/public/\(bucketId)/\(path)?download=test&width=300&height=300&quality=80" ) }