diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 402548b1..a36e6791 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,20 +30,10 @@ jobs: run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: List available devices run: xcrun simctl list devices available - - name: Cache derived data - uses: actions/cache@v3 - with: - path: | - ~/.derivedData - key: | - deriveddata-xcodebuild-${{ matrix.platform }}-${{ matrix.xcode }}-${{ matrix.command }}-${{ hashFiles('**/Sources/**/*.swift', '**/Tests/**/*.swift') }} - restore-keys: | - deriveddata-xcodebuild-${{ matrix.platform }}-${{ matrix.xcode }}-${{ matrix.command }}- - name: Set IgnoreFileSystemDeviceInodeChanges flag run: defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES - name: Update mtime for incremental builds uses: chetan/git-restore-mtime-action@v2 - - run: make dot-env - name: Debug run: make XCODEBUILD_ARGUMENT="${{ matrix.command }}" CONFIG=Debug PLATFORM="${{ matrix.platform }}" xcodebuild - name: Release @@ -62,7 +52,6 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} file: lcov.info - xcodebuild: name: xcodebuild (15) runs-on: macos-14 @@ -77,6 +66,7 @@ jobs: - { xcode: 15.2, platform: TVOS } - { xcode: 15.2, platform: VISIONOS } - { xcode: 15.2, platform: WATCHOS } + - { command: test, platform: WATCHOS } include: - { command: test, skip_release: 1 } steps: @@ -105,7 +95,6 @@ jobs: run: defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES - name: Update mtime for incremental builds uses: chetan/git-restore-mtime-action@v2 - - run: make dot-env - name: Debug run: make XCODEBUILD_ARGUMENT="${{ matrix.command }}" CONFIG=Debug PLATFORM="${{ matrix.platform }}" xcodebuild - name: Release @@ -132,7 +121,6 @@ jobs: # build-spm-linux-${{ matrix.swift-version }}-${{ hashFiles('**/Sources/**/*.swift', '**/Tests/**/*.swift', '**/Package.resolved') }} # restore-keys: | # build-spm-linux-${{ matrix.swift-version }}- - # - run: make dot-env # - name: Run tests # run: swift test --skip IntegrationTests diff --git a/.github/workflows/integration-tests.yml b/.github/workflows_disabled/integration-tests.yml similarity index 100% rename from .github/workflows/integration-tests.yml rename to .github/workflows_disabled/integration-tests.yml diff --git a/.gitignore b/.gitignore index 3c09847f..087a8828 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,5 @@ iOSInjectionProject/ # Environment .env Secrets.swift -DotEnv.swift lcov.info temp_coverage \ No newline at end of file diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme index 932de571..6a52636f 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Supabase.xcscheme @@ -97,7 +97,52 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - codeCoverageEnabled = "YES"> + codeCoverageEnabled = "YES" + onlyGenerateCoverageForSpecifiedTargets = "YES"> + + + + + + + + + + + + + + diff --git a/Makefile b/Makefile index 6e582059..dc05f17d 100644 --- a/Makefile +++ b/Makefile @@ -38,26 +38,13 @@ endif TEST_RUNNER_CI = $(CI) -export SECRETS -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 - xcodebuild: $(XCODEBUILD) -load-env: - @. ./scripts/load_env.sh - -dot-env: - @echo "$$SECRETS" > Tests/IntegrationTests/DotEnv.swift - -test-integration: dot-env - $(MAKE) TEST_PLAN=Integration xcodebuild +test-integration: + cd Tests/IntegrationTests && supabase start && supabase db reset + swift test --filter IntegrationTests + cd Tests/IntegrationTests && supabase stop build-for-library-evolution: swift build \ @@ -107,4 +94,4 @@ coverage: define udid_for $(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | awk -F '[()]' '{ print $$(NF-3) }') -endef +endef \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index 284ef0a6..7c5de6d3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "mocker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/WeTransfer/Mocker", + "state" : { + "revision" : "95fa785c751f6bc40c49e112d433c3acf8417a97", + "version" : "3.0.2" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 0daf6b54..785900e7 100644 --- a/Package.swift +++ b/Package.swift @@ -24,13 +24,14 @@ let package = Package( targets: ["Supabase", "Functions", "PostgREST", "Auth", "Realtime", "Storage"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"), + .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), + .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), - .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.2"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"), .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), - .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), + .package(url: "https://github.com/WeTransfer/Mocker", from: "3.0.0"), ], targets: [ .target( @@ -39,6 +40,7 @@ let package = Package( .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "Clocks", package: "swift-clocks"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), ] ), .testTarget( @@ -60,11 +62,11 @@ let package = Package( name: "AuthTests", dependencies: [ .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), - "Helpers", "Auth", + "Helpers", "TestHelpers", ], exclude: [ @@ -82,12 +84,13 @@ let package = Package( name: "FunctionsTests", dependencies: [ .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), "Functions", + "Mocker", "TestHelpers", - ], - exclude: ["__Snapshots__"] + ] ), .testTarget( name: "IntegrationTests", @@ -99,7 +102,10 @@ let package = Package( "Supabase", "TestHelpers", ], - resources: [.process("Fixtures")] + resources: [ + .process("Fixtures"), + .process("supabase"), + ] ), .target( name: "PostgREST", @@ -111,11 +117,13 @@ let package = Package( .testTarget( name: "PostgRESTTests", dependencies: [ + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), "Helpers", + "Mocker", "PostgREST", - ], - exclude: ["__Snapshots__"] + "TestHelpers", + ] ), .target( name: "Realtime", @@ -129,8 +137,8 @@ let package = Package( name: "RealtimeTests", dependencies: [ .product(name: "CustomDump", package: "swift-custom-dump"), - .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), "PostgREST", "Realtime", "TestHelpers", @@ -149,6 +157,9 @@ let package = Package( .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), "Storage", + ], + resources: [ + .copy("sadcat.jpg") ] ), .target( @@ -174,8 +185,10 @@ let package = Package( name: "TestHelpers", dependencies: [ .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), "Auth", + "Mocker", ] ), ] diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index e9be2f71..5ee7e577 100644 --- a/Sources/Functions/FunctionsClient.swift +++ b/Sources/Functions/FunctionsClient.swift @@ -1,7 +1,7 @@ import ConcurrencyExtras import Foundation -import Helpers import HTTPTypes +import Helpers #if canImport(FoundationNetworking) import FoundationNetworking @@ -29,6 +29,7 @@ public final class FunctionsClient: Sendable { private let http: any HTTPClientType private let mutableState = LockIsolated(MutableState()) + private let sessionConfiguration: URLSessionConfiguration var headers: HTTPFields { mutableState.headers @@ -49,6 +50,24 @@ public final class FunctionsClient: Sendable { region: String? = nil, logger: (any SupabaseLogger)? = nil, fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } + ) { + self.init( + url: url, + headers: headers, + region: region, + logger: logger, + fetch: fetch, + sessionConfiguration: .default + ) + } + + convenience init( + url: URL, + headers: [String: String] = [:], + region: String? = nil, + logger: (any SupabaseLogger)? = nil, + fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, + sessionConfiguration: URLSessionConfiguration ) { var interceptors: [any HTTPClientInterceptor] = [] if let logger { @@ -57,18 +76,26 @@ public final class FunctionsClient: Sendable { let http = HTTPClient(fetch: fetch, interceptors: interceptors) - self.init(url: url, headers: headers, region: region, http: http) + self.init( + url: url, + headers: headers, + region: region, + http: http, + sessionConfiguration: sessionConfiguration + ) } init( url: URL, headers: [String: String], region: String?, - http: any HTTPClientType + http: any HTTPClientType, + sessionConfiguration: URLSessionConfiguration = .default ) { self.url = url self.region = region self.http = http + self.sessionConfiguration = sessionConfiguration mutableState.withValue { $0.headers = HTTPFields(headers) @@ -164,7 +191,7 @@ public final class FunctionsClient: Sendable { let request = buildRequest(functionName: functionName, options: invokeOptions) let response = try await http.send(request) - guard 200 ..< 300 ~= response.statusCode else { + guard 200..<300 ~= response.statusCode else { throw FunctionsError.httpError(code: response.statusCode, data: response.data) } @@ -194,7 +221,8 @@ public final class FunctionsClient: Sendable { let (stream, continuation) = AsyncThrowingStream.makeStream() let delegate = StreamResponseDelegate(continuation: continuation) - let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) + let session = URLSession( + configuration: sessionConfiguration, delegate: delegate, delegateQueue: nil) let urlRequest = buildRequest(functionName: functionName, options: invokeOptions).urlRequest @@ -211,10 +239,12 @@ public final class FunctionsClient: Sendable { return stream } - private func buildRequest(functionName: String, options: FunctionInvokeOptions) -> Helpers.HTTPRequest { + private func buildRequest(functionName: String, options: FunctionInvokeOptions) + -> Helpers.HTTPRequest + { var request = HTTPRequest( url: url.appendingPathComponent(functionName), - method: options.httpMethod ?? .post, + method: FunctionInvokeOptions.httpMethod(options.method) ?? .post, query: options.query, headers: mutableState.headers.merging(with: options.headers), body: options.body @@ -243,14 +273,24 @@ final class StreamResponseDelegate: NSObject, URLSessionDataDelegate, Sendable { continuation.finish(throwing: error) } - func urlSession(_: URLSession, dataTask _: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + func urlSession( + _: URLSession, dataTask _: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + defer { + completionHandler(.allow) + } + guard let httpResponse = response as? HTTPURLResponse else { continuation.finish(throwing: URLError(.badServerResponse)) return } - guard 200 ..< 300 ~= httpResponse.statusCode else { - let error = FunctionsError.httpError(code: httpResponse.statusCode, data: Data()) + guard 200..<300 ~= httpResponse.statusCode else { + let error = FunctionsError.httpError( + code: httpResponse.statusCode, + data: Data() + ) continuation.finish(throwing: error) return } @@ -259,6 +299,5 @@ final class StreamResponseDelegate: NSObject, URLSessionDataDelegate, Sendable { if isRelayError { continuation.finish(throwing: FunctionsError.relayError) } - completionHandler(.allow) } } diff --git a/Sources/Functions/Types.swift b/Sources/Functions/Types.swift index e409c665..e69f036e 100644 --- a/Sources/Functions/Types.swift +++ b/Sources/Functions/Types.swift @@ -1,6 +1,6 @@ import Foundation -import Helpers import HTTPTypes +import Helpers /// An error type representing various errors that can occur while invoking functions. public enum FunctionsError: Error, LocalizedError { @@ -99,7 +99,7 @@ public struct FunctionInvokeOptions: Sendable { case delete = "DELETE" } - var httpMethod: HTTPTypes.HTTPRequest.Method? { + static func httpMethod(_ method: Method?) -> HTTPTypes.HTTPRequest.Method? { switch method { case .get: .get diff --git a/Sources/Helpers/Version.swift b/Sources/Helpers/Version.swift index 490d7258..052341a1 100644 --- a/Sources/Helpers/Version.swift +++ b/Sources/Helpers/Version.swift @@ -1 +1,9 @@ -package let version = "2.24.4" // {x-release-please-version} +import XCTestDynamicOverlay + +private let _version = "2.24.4" // {x-release-please-version} + +#if DEBUG + package let version = isTesting ? "0.0.0" : _version +#else + package let version = _version +#endif diff --git a/Sources/TestHelpers/MockExtensions.swift b/Sources/TestHelpers/MockExtensions.swift new file mode 100644 index 00000000..8b585d31 --- /dev/null +++ b/Sources/TestHelpers/MockExtensions.swift @@ -0,0 +1,43 @@ +// +// MockExtensions.swift +// Supabase +// +// Created by Guilherme Souza on 21/01/25. +// + +import Mocker +import Foundation +import InlineSnapshotTesting + +extension Mock { + package func snapshotRequest( + message: @autoclosure () -> String = "", + record isRecording: Bool? = nil, + timeout: TimeInterval = 5, + syntaxDescriptor: InlineSnapshotSyntaxDescriptor = InlineSnapshotSyntaxDescriptor(), + matches expected: (() -> String)? = nil, + fileID: StaticString = #fileID, + file filePath: StaticString = #filePath, + function: StaticString = #function, + line: UInt = #line, + column: UInt = #column + ) -> Self { + var copy = self + copy.onRequestHandler = OnRequestHandler { + assertInlineSnapshot( + of: $0, + as: ._curl, + record: isRecording, + timeout: timeout, + syntaxDescriptor: syntaxDescriptor, + matches: expected, + fileID: fileID, + file: filePath, + function: function, + line: line, + column: column + ) + } + return copy + } +} diff --git a/Sources/TestHelpers/URLRequestSnapshot.swift b/Sources/TestHelpers/URLRequestSnapshot.swift new file mode 100644 index 00000000..0c17b458 --- /dev/null +++ b/Sources/TestHelpers/URLRequestSnapshot.swift @@ -0,0 +1,110 @@ +// +// SnapshotStrategy.swift +// Supabase +// +// Created by Guilherme Souza on 22/01/25. +// + +@preconcurrency import InlineSnapshotTesting + +#if !os(WASI) + import Foundation + + #if canImport(FoundationNetworking) + import FoundationNetworking + #endif + + extension Snapshotting where Value == URLRequest, Format == String { + /// A snapshot strategy for comparing requests based on a cURL representation. + /// + // ``` swift + // assertSnapshot(of: request, as: .curl) + // ``` + // + // Records: + // + // ``` + // curl \ + // --request POST \ + // --header "Accept: text/html" \ + // --data 'pricing[billing]=monthly&pricing[lane]=individual' \ + // "https://www.pointfree.co/subscribe" + // ``` + package static let _curl = SimplySnapshotting.lines.pullback { (request: URLRequest) in + + var components = ["curl"] + + // HTTP Method + let httpMethod = request.httpMethod! + switch httpMethod { + case "GET": break + case "HEAD": components.append("--head") + default: components.append("--request \(httpMethod)") + } + + // Headers + if let headers = request.allHTTPHeaderFields { + for field in headers.keys.sorted() where field != "Cookie" { + let escapedValue = headers[field]!.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--header \"\(field): \(escapedValue)\"") + } + } + + // Body + if let httpBodyData = request.data, + let httpBody = String(data: httpBodyData, encoding: .utf8) + { + var escapedBody = httpBody.replacingOccurrences(of: "\\\"", with: "\\\\\"") + escapedBody = escapedBody.replacingOccurrences(of: "\"", with: "\\\"") + + components.append("--data \"\(escapedBody)\"") + } + + // Cookies + if let cookie = request.allHTTPHeaderFields?["Cookie"] { + let escapedValue = cookie.replacingOccurrences(of: "\"", with: "\\\"") + components.append("--cookie \"\(escapedValue)\"") + } + + // URL + components.append("\"\(request.url!.sortingQueryItems()!.absoluteString)\"") + + return components.joined(separator: " \\\n\t") + } + } + + extension URL { + fileprivate func sortingQueryItems() -> URL? { + var components = URLComponents(url: self, resolvingAgainstBaseURL: false) + let sortedQueryItems = components?.queryItems?.sorted { $0.name < $1.name } + components?.queryItems = sortedQueryItems + + return components?.url + } + } + + extension URLRequest { + fileprivate var data: Data? { + httpBody ?? httpBodyStream.map { Data(reading: $0, withBufferSize: 1024) } + } + } + + extension Data { + fileprivate init(reading stream: InputStream, withBufferSize bufferSize: UInt = 1024) { + self.init() + + stream.open() + defer { stream.close() } + + let bufferSize = Int(bufferSize) + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + guard read > 0 else { return } + self.append(buffer, count: read) + } + } + } +#endif diff --git a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved index 953722c6..4a7be132 100644 --- a/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Supabase.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -36,6 +36,15 @@ "version" : "4.1.1" } }, + { + "identity" : "mocker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/WeTransfer/Mocker", + "state" : { + "revision" : "95fa785c751f6bc40c49e112d433c3acf8417a97", + "version" : "3.0.2" + } + }, { "identity" : "svgview", "kind" : "remoteSourceControl", diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index 1152c2d1..b48cbf24 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -527,7 +527,7 @@ final class RequestsTests: XCTestCase { fetch: { request in DispatchQueue.main.sync { assertSnapshot( - of: request, as: .curl, record: record, file: file, testName: testName, line: line + of: request, as: ._curl, record: record, file: file, testName: testName, line: line ) } diff --git a/Tests/FunctionsTests/FunctionInvokeOptionsTests.swift b/Tests/FunctionsTests/FunctionInvokeOptionsTests.swift index 4a781007..0c050086 100644 --- a/Tests/FunctionsTests/FunctionInvokeOptionsTests.swift +++ b/Tests/FunctionsTests/FunctionInvokeOptionsTests.swift @@ -1,3 +1,4 @@ +import HTTPTypes import XCTest @testable import Functions @@ -34,4 +35,18 @@ final class FunctionInvokeOptionsTests: XCTestCase { XCTAssertEqual(options.headers[.contentType], contentType) XCTAssertNotNil(options.body) } + + func testMethod() { + let testCases: [FunctionInvokeOptions.Method: HTTPTypes.HTTPRequest.Method] = [ + .get: .get, + .post: .post, + .put: .put, + .patch: .patch, + .delete: .delete, + ] + + for (method, expected) in testCases { + XCTAssertEqual(FunctionInvokeOptions.httpMethod(method), expected) + } + } } diff --git a/Tests/FunctionsTests/FunctionsClientTests.swift b/Tests/FunctionsTests/FunctionsClientTests.swift index e4972dce..7e2535a6 100644 --- a/Tests/FunctionsTests/FunctionsClientTests.swift +++ b/Tests/FunctionsTests/FunctionsClientTests.swift @@ -1,89 +1,156 @@ import ConcurrencyExtras -@testable import Functions -import Helpers import HTTPTypes +import Helpers +import InlineSnapshotTesting +import Mocker import TestHelpers import XCTest +@testable import Functions + #if canImport(FoundationNetworking) import FoundationNetworking #endif final class FunctionsClientTests: XCTestCase { let url = URL(string: "http://localhost:5432/functions/v1")! - let apiKey = "supabase.anon.key" - - lazy var sut = FunctionsClient(url: url, headers: ["Apikey": apiKey]) + let apiKey = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" + + let sessionConfiguration: URLSessionConfiguration = { + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.protocolClasses = [MockingURLProtocol.self] + return sessionConfiguration + }() + + lazy var session = URLSession(configuration: sessionConfiguration) + + var region: String? + + lazy var sut = FunctionsClient( + url: url, + headers: [ + "apikey": apiKey + ], + region: region, + fetch: { request in + try await self.session.data(for: request) + }, + sessionConfiguration: sessionConfiguration + ) + + override func setUp() { + super.setUp() + // isRecording = true + } func testInit() async { let client = FunctionsClient( url: url, - headers: ["Apikey": apiKey], + headers: ["apikey": apiKey], region: .saEast1 ) XCTAssertEqual(client.region, "sa-east-1") - XCTAssertEqual(client.headers[.init("Apikey")!], apiKey) + XCTAssertEqual(client.headers[.init("apikey")!], apiKey) XCTAssertNotNil(client.headers[.init("X-Client-Info")!]) } func testInvoke() async throws { - let url = URL(string: "http://localhost:5432/functions/v1/hello_world")! - - let http = await HTTPClientMock() - .when { - $0.url.pathComponents.contains("hello_world") - } return: { _ in - try .stub(body: Empty()) - } - let sut = FunctionsClient( - url: self.url, - headers: ["Apikey": apiKey], - region: nil, - http: http + Mock( + url: self.url.appendingPathComponent("hello_world"), + statusCode: 200, + data: [.post: Data()] ) - - let body = ["name": "Supabase"] + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 19" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "X-Custom-Key: value" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"name\":\"Supabase\"}" \ + "http://localhost:5432/functions/v1/hello_world" + """# + } + .register() try await sut.invoke( "hello_world", - options: .init(headers: ["X-Custom-Key": "value"], body: body) + options: .init(headers: ["X-Custom-Key": "value"], body: ["name": "Supabase"]) ) + } - let request = await http.receivedRequests.last + func testInvokeReturningDecodable() async throws { + Mock( + url: url.appendingPathComponent("hello"), + statusCode: 200, + data: [ + .post: #"{"message":"Hello, world!","status":"ok"}"#.data(using: .utf8)! + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/hello" + """# + } + .register() - XCTAssertEqual(request?.url, url) - XCTAssertEqual(request?.method, .post) - XCTAssertEqual(request?.headers[.init("Apikey")!], apiKey) - XCTAssertEqual(request?.headers[.init("X-Custom-Key")!], "value") - XCTAssertEqual(request?.headers[.init("X-Client-Info")!], "functions-swift/\(Functions.version)") + struct Payload: Decodable { + var message: String + var status: String + } + + let response = try await sut.invoke("hello") as Payload + XCTAssertEqual(response.message, "Hello, world!") + XCTAssertEqual(response.status, "ok") } func testInvokeWithCustomMethod() async throws { - let http = await HTTPClientMock().any { _ in try .stub(body: Empty()) } - - let sut = FunctionsClient( - url: url, - headers: ["Apikey": apiKey], - region: nil, - http: http + Mock( + url: url.appendingPathComponent("hello-world"), + statusCode: 200, + data: [.delete: Data()] ) + .snapshotRequest { + #""" + curl \ + --request DELETE \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/hello-world" + """# + } + .register() try await sut.invoke("hello-world", options: .init(method: .delete)) - - let request = await http.receivedRequests.last - XCTAssertEqual(request?.method, .delete) } func testInvokeWithQuery() async throws { - let http = await HTTPClientMock().any { _ in try .stub(body: Empty()) } - - let sut = FunctionsClient( - url: url, - headers: ["Apikey": apiKey], - region: nil, - http: http + Mock( + url: url.appendingPathComponent("hello-world"), + ignoreQuery: true, + statusCode: 200, + data: [ + .post: Data() + ] ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/hello-world?key=value" + """# + } + .register() try await sut.invoke( "hello-world", @@ -91,73 +158,95 @@ final class FunctionsClientTests: XCTestCase { query: [URLQueryItem(name: "key", value: "value")] ) ) - - let request = await http.receivedRequests.last - XCTAssertEqual(request?.urlRequest.url?.query, "key=value") } func testInvokeWithRegionDefinedInClient() async throws { - let http = await HTTPClientMock() - .any { _ in try .stub(body: Empty()) } + region = FunctionRegion.caCentral1.rawValue - let sut = FunctionsClient( - url: url, - headers: [:], - region: FunctionRegion.caCentral1.rawValue, - http: http + Mock( + url: url.appendingPathComponent("hello-world"), + statusCode: 200, + data: [.post: Data()] ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --header "x-region: ca-central-1" \ + "http://localhost:5432/functions/v1/hello-world" + """# + } + .register() try await sut.invoke("hello-world") - - let request = await http.receivedRequests.last - XCTAssertEqual(request?.headers[.xRegion], "ca-central-1") } func testInvokeWithRegion() async throws { - let http = await HTTPClientMock() - .any { _ in try .stub(body: Empty()) } - - let sut = FunctionsClient( - url: url, - headers: [:], - region: nil, - http: http + Mock( + url: url.appendingPathComponent("hello-world"), + statusCode: 200, + data: [.post: Data()] ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --header "x-region: ca-central-1" \ + "http://localhost:5432/functions/v1/hello-world" + """# + } + .register() try await sut.invoke("hello-world", options: .init(region: .caCentral1)) - - let request = await http.receivedRequests.last - XCTAssertEqual(request?.headers[.xRegion], "ca-central-1") } func testInvokeWithoutRegion() async throws { - let http = await HTTPClientMock() - .any { _ in try .stub(body: Empty()) } + region = nil - let sut = FunctionsClient( - url: url, - headers: [:], - region: nil, - http: http + Mock( + url: url.appendingPathComponent("hello-world"), + statusCode: 200, + data: [.post: Data()] ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/hello-world" + """# + } + .register() try await sut.invoke("hello-world") - - let request = await http.receivedRequests.last - XCTAssertNil(request?.headers[.xRegion]) } func testInvoke_shouldThrow_URLError_badServerResponse() async { - let sut = await FunctionsClient( - url: url, - headers: ["Apikey": apiKey], - region: nil, - http: HTTPClientMock() - .any { _ in throw URLError(.badServerResponse) } + Mock( + url: url.appendingPathComponent("hello_world"), + statusCode: 200, + data: [.post: Data()], + requestError: URLError(.badServerResponse) ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/hello_world" + """# + } + .register() do { try await sut.invoke("hello_world") + XCTFail("Invoke should fail.") } catch let urlError as URLError { XCTAssertEqual(urlError.code, .badServerResponse) } catch { @@ -166,13 +255,22 @@ final class FunctionsClientTests: XCTestCase { } func testInvoke_shouldThrow_FunctionsError_httpError() async { - let sut = await FunctionsClient( - url: url, - headers: ["Apikey": apiKey], - region: nil, - http: HTTPClientMock() - .any { _ in try .stub(body: Empty(), statusCode: 300) } + Mock( + url: url.appendingPathComponent("hello_world"), + statusCode: 300, + data: [.post: Data()] ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/hello_world" + """# + } + .register() + do { try await sut.invoke("hello_world") XCTFail("Invoke should fail.") @@ -184,17 +282,24 @@ final class FunctionsClientTests: XCTestCase { } func testInvoke_shouldThrow_FunctionsError_relayError() async { - let sut = await FunctionsClient( - url: url, - headers: ["Apikey": apiKey], - region: nil, - http: HTTPClientMock().any { _ in - try .stub( - body: Empty(), - headers: [.xRelayError: "true"] - ) - } + Mock( + url: url.appendingPathComponent("hello_world"), + statusCode: 200, + data: [.post: Data()], + additionalHeaders: [ + "x-relay-error": "true" + ] ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/hello_world" + """# + } + .register() do { try await sut.invoke("hello_world") @@ -208,27 +313,90 @@ final class FunctionsClientTests: XCTestCase { func test_setAuth() { sut.setAuth(token: "access.token") XCTAssertEqual(sut.headers[.authorization], "Bearer access.token") + + sut.setAuth(token: nil) + XCTAssertNil(sut.headers[.authorization]) } -} -extension Helpers.HTTPResponse { - static func stub( - body: any Encodable, - statusCode: Int = 200, - headers: HTTPFields = .init() - ) throws -> Helpers.HTTPResponse { - let data = try JSONEncoder().encode(body) - let response = HTTPURLResponse( - url: URL(string: "http://127.0.0.1")!, - statusCode: statusCode, - httpVersion: nil, - headerFields: headers.dictionary - )! - return HTTPResponse( - data: data, - response: response + func testInvokeWithStreamedResponse() async throws { + Mock( + url: url.appendingPathComponent("stream"), + statusCode: 200, + data: [.post: Data("hello world".utf8)] ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/stream" + """# + } + .register() + + let stream = sut._invokeWithStreamedResponse("stream") + + for try await value in stream { + XCTAssertEqual(String(decoding: value, as: UTF8.self), "hello world") + } } -} -struct Empty: Codable {} + func testInvokeWithStreamedResponseHTTPError() async throws { + Mock( + url: url.appendingPathComponent("stream"), + statusCode: 300, + data: [.post: Data()] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/stream" + """# + } + .register() + + let stream = sut._invokeWithStreamedResponse("stream") + + do { + for try await _ in stream { + XCTFail("should throw error") + } + } catch let FunctionsError.httpError(code, _) { + XCTAssertEqual(code, 300) + } + } + + func testInvokeWithStreamedResponseRelayError() async throws { + Mock( + url: url.appendingPathComponent("stream"), + statusCode: 200, + data: [.post: Data()], + additionalHeaders: [ + "x-relay-error": "true" + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "X-Client-Info: functions-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:5432/functions/v1/stream" + """# + } + .register() + + let stream = sut._invokeWithStreamedResponse("stream") + + do { + for try await _ in stream { + XCTFail("should throw error") + } + } catch FunctionsError.relayError { + } + } +} diff --git a/Tests/FunctionsTests/FunctionsErrorTests.swift b/Tests/FunctionsTests/FunctionsErrorTests.swift new file mode 100644 index 00000000..706abcf2 --- /dev/null +++ b/Tests/FunctionsTests/FunctionsErrorTests.swift @@ -0,0 +1,20 @@ +// +// FunctionsErrorTests.swift +// Supabase +// +// Created by Guilherme Souza on 20/01/25. +// + +import Supabase +import XCTest + +final class FunctionsErrorTests: XCTestCase { + + func testLocalizedDescription() { + XCTAssertEqual( + FunctionsError.relayError.localizedDescription, "Relay Error invoking the Edge Function") + XCTAssertEqual( + FunctionsError.httpError(code: 412, data: Data()).localizedDescription, + "Edge Function returned a non-2xx status code: 412") + } +} diff --git a/Tests/IntegrationTests/supabase/.branches/_current_branch b/Tests/IntegrationTests/supabase/.branches/_current_branch new file mode 100644 index 00000000..88d050b1 --- /dev/null +++ b/Tests/IntegrationTests/supabase/.branches/_current_branch @@ -0,0 +1 @@ +main \ No newline at end of file diff --git a/Tests/IntegrationTests/supabase/.temp/cli-latest b/Tests/IntegrationTests/supabase/.temp/cli-latest new file mode 100644 index 00000000..e4af78e3 --- /dev/null +++ b/Tests/IntegrationTests/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.6.8 \ No newline at end of file