diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 402548b1..65b3ac84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,20 +30,19 @@ 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: 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,79 +61,77 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} file: lcov.info - - xcodebuild: - name: xcodebuild (15) - runs-on: macos-14 - strategy: - matrix: - command: [test, ""] - platform: [IOS, MAC_CATALYST, MACOS, TVOS, VISIONOS, WATCHOS] - xcode: [15.2, 15.4] - exclude: - - { xcode: 15.2, command: test } - - { xcode: 15.2, platform: MAC_CATALYST } - - { xcode: 15.2, platform: TVOS } - - { xcode: 15.2, platform: VISIONOS } - - { xcode: 15.2, platform: WATCHOS } - include: - - { command: test, skip_release: 1 } - steps: - - uses: actions/checkout@v4 - - name: Select Xcode ${{ matrix.xcode }} - run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - - name: Install visionOS runtime - if: matrix.platform == 'visionOS' - run: | - sudo xcodebuild -runFirstLaunch - sudo xcrun simctl list - sudo xcodebuild -downloadPlatform visionOS - sudo xcodebuild -runFirstLaunch - - 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 - if: matrix.skip_release != '1' - run: make XCODEBUILD_ARGUMENT="${{ matrix.command }}" CONFIG=Release PLATFORM="${{ matrix.platform }}" xcodebuild - - # linux: - # name: linux + # xcodebuild: + # name: xcodebuild (15) + # runs-on: macos-14 # strategy: # matrix: - # swift-version: ["5.10"] - # runs-on: ubuntu-latest + # command: [test, ""] + # platform: [IOS, MAC_CATALYST, MACOS, TVOS, VISIONOS, WATCHOS] + # xcode: [15.2, 15.4] + # exclude: + # - { xcode: 15.2, command: test } + # - { xcode: 15.2, platform: MAC_CATALYST } + # - { xcode: 15.2, platform: TVOS } + # - { xcode: 15.2, platform: VISIONOS } + # - { xcode: 15.2, platform: WATCHOS } + # include: + # - { command: test, skip_release: 1 } # steps: # - uses: actions/checkout@v4 - # - uses: swift-actions/setup-swift@v2 - # with: - # swift-version: ${{ matrix.swift-version }} - # - name: Cache build + # - name: Select Xcode ${{ matrix.xcode }} + # run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + # - name: Install visionOS runtime + # if: matrix.platform == 'visionOS' + # run: | + # sudo xcodebuild -runFirstLaunch + # sudo xcrun simctl list + # sudo xcodebuild -downloadPlatform visionOS + # sudo xcodebuild -runFirstLaunch + # - name: List available devices + # run: xcrun simctl list devices available + # - name: Cache derived data # uses: actions/cache@v3 # with: # path: | - # .build + # ~/.derivedData # key: | - # build-spm-linux-${{ matrix.swift-version }}-${{ hashFiles('**/Sources/**/*.swift', '**/Tests/**/*.swift', '**/Package.resolved') }} + # 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 + # - name: Debug + # run: make XCODEBUILD_ARGUMENT="${{ matrix.command }}" CONFIG=Debug PLATFORM="${{ matrix.platform }}" xcodebuild + # - name: Release + # if: matrix.skip_release != '1' + # run: make XCODEBUILD_ARGUMENT="${{ matrix.command }}" CONFIG=Release PLATFORM="${{ matrix.platform }}" xcodebuild + + # linux: + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@v4 + # - uses: supabase/setup-cli@v1 + # - uses: actions/cache@v4 + # with: + # path: .build + # key: ${{ runner.os }}-spm-${{ hashFiles('**/Package.resolved') }} # restore-keys: | - # build-spm-linux-${{ matrix.swift-version }}- - # - run: make dot-env + # ${{ runner.os }}-spm- + # - run: supabase start + # working-directory: ./Tests/IntegrationTests # - name: Run tests - # run: swift test --skip IntegrationTests + # run: swift test + # env: + # SUPABASE_URL: http://localhost:54321 + # SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 + # SUPABASE_SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + # - name: Stop infrastructure + # if: always() + # run: supabase stop + # working-directory: ./Tests/IntegrationTests # library-evolution: # name: Library (evolution) @@ -146,28 +143,28 @@ jobs: # - name: Build for library evolution # run: make build-for-library-evolution - examples: - name: Examples - runs-on: macos-15 - steps: - - uses: actions/checkout@v4 - - name: Cache derived data - uses: actions/cache@v3 - with: - path: ~/.derivedData - key: | - deriveddata-examples-${{ hashFiles('**/Sources/**/*.swift', '**/Tests/**/*.swift', '**/Examples/**/*.swift') }} - restore-keys: | - deriveddata-examples- - - name: Select Xcode 16 - run: sudo xcode-select -s /Applications/Xcode_16.0.app - - 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 - - name: Examples - run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="Examples" XCODEBUILD_ARGUMENT=build xcodebuild - - name: SlackClone - run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="SlackClone" XCODEBUILD_ARGUMENT=build xcodebuild - - name: UserManagement - run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="UserManagement" XCODEBUILD_ARGUMENT=build xcodebuild + # examples: + # name: Examples + # runs-on: macos-15 + # steps: + # - uses: actions/checkout@v4 + # - name: Cache derived data + # uses: actions/cache@v3 + # with: + # path: ~/.derivedData + # key: | + # deriveddata-examples-${{ hashFiles('**/Sources/**/*.swift', '**/Tests/**/*.swift', '**/Examples/**/*.swift') }} + # restore-keys: | + # deriveddata-examples- + # - name: Select Xcode 16 + # run: sudo xcode-select -s /Applications/Xcode_16.0.app + # - 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 + # - name: Examples + # run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="Examples" XCODEBUILD_ARGUMENT=build xcodebuild + # - name: SlackClone + # run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="SlackClone" XCODEBUILD_ARGUMENT=build xcodebuild + # - name: UserManagement + # run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="UserManagement" XCODEBUILD_ARGUMENT=build xcodebuild diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml deleted file mode 100644 index 6ca1cb80..00000000 --- a/.github/workflows/integration-tests.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Integration Tests - -on: - push: - branches: - - main - - release/* - workflow_dispatch: - -concurrency: - group: integration-tests-${{ github.ref }} - cancel-in-progress: true - -jobs: - integration-tests: - runs-on: macos-15 - name: Integration Tests - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - name: Select Xcode 16 - run: sudo xcode-select -s /Applications/Xcode_16.0.app - - name: Run tests - run: make test-integration - env: - SUPABASE_URL: ${{ secrets.SUPABASE_URL }} - SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} - SUPABASE_SERVICE_ROLE_KEY: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} diff --git a/.github/workflows_disabled/auth.yml b/.github/workflows_disabled/auth.yml index 68c4283a..a134ddfd 100644 --- a/.github/workflows_disabled/auth.yml +++ b/.github/workflows_disabled/auth.yml @@ -50,6 +50,5 @@ jobs: - uses: swift-actions/setup-swift@v2 with: swift-version: ${{ matrix.swift-version }} - - run: make dot-env - name: Run tests run: swift test --filter AuthTests. diff --git a/.github/workflows_disabled/functions.yml b/.github/workflows_disabled/functions.yml index d1c0c404..58d07d83 100644 --- a/.github/workflows_disabled/functions.yml +++ b/.github/workflows_disabled/functions.yml @@ -49,6 +49,5 @@ jobs: - uses: swift-actions/setup-swift@v2 with: swift-version: ${{ matrix.swift-version }} - - run: make dot-env - name: Run tests run: swift test --filter FunctionsTests. \ No newline at end of file diff --git a/.github/workflows_disabled/integration-tests.yml b/.github/workflows_disabled/integration-tests.yml new file mode 100644 index 00000000..111a7c46 --- /dev/null +++ b/.github/workflows_disabled/integration-tests.yml @@ -0,0 +1,32 @@ +name: Integration Tests + +on: + push: + branches: + - main + - release/* + workflow_dispatch: + +concurrency: + group: integration-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + integration-tests: + runs-on: ubuntu-latest + name: Integration Tests + steps: + - uses: actions/checkout@v4 + - uses: supabase/setup-cli@v1 + - run: supabase start + working-directory: ./Tests/IntegrationTests + - name: Run tests + run: swift test --filter IntegrationTests + env: + SUPABASE_URL: http://localhost:54321 + SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 + SUPABASE_SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + - name: Stop infrastructure + if: always() + run: supabase stop + working-directory: ./Tests/IntegrationTests diff --git a/.github/workflows_disabled/postgrest.yml b/.github/workflows_disabled/postgrest.yml index 01e1087a..0b4c8de3 100644 --- a/.github/workflows_disabled/postgrest.yml +++ b/.github/workflows_disabled/postgrest.yml @@ -49,6 +49,5 @@ jobs: - uses: swift-actions/setup-swift@v2 with: swift-version: ${{ matrix.swift-version }} - - run: make dot-env - name: Run tests run: swift test --filter PostgRESTTests. \ No newline at end of file diff --git a/.github/workflows_disabled/realtime.yml b/.github/workflows_disabled/realtime.yml index 477de541..c61f1047 100644 --- a/.github/workflows_disabled/realtime.yml +++ b/.github/workflows_disabled/realtime.yml @@ -49,6 +49,5 @@ jobs: - uses: swift-actions/setup-swift@v2 with: swift-version: ${{ matrix.swift-version }} - - run: make dot-env - name: Run tests run: swift test --filter RealtimeTests. \ No newline at end of file diff --git a/.github/workflows_disabled/storage.yml b/.github/workflows_disabled/storage.yml index cbeafeba..1bb1787b 100644 --- a/.github/workflows_disabled/storage.yml +++ b/.github/workflows_disabled/storage.yml @@ -49,6 +49,5 @@ jobs: - uses: swift-actions/setup-swift@v2 with: swift-version: ${{ matrix.swift-version }} - - run: make dot-env - name: Run tests run: swift test --filter StorageTests. \ No newline at end of file diff --git a/.github/workflows_disabled/supabase.yml b/.github/workflows_disabled/supabase.yml index 21464992..97d630be 100644 --- a/.github/workflows_disabled/supabase.yml +++ b/.github/workflows_disabled/supabase.yml @@ -49,6 +49,5 @@ jobs: - uses: swift-actions/setup-swift@v2 with: swift-version: ${{ matrix.swift-version }} - - run: make dot-env - name: Run tests run: swift test --filter SupabaseTests. \ No newline at end of file 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/Storage.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Storage.xcscheme index c380006b..122d405d 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/Storage.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Storage.xcscheme @@ -1,7 +1,7 @@ + version = "1.3"> 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..1a3e4a3b 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 \ diff --git a/Package.resolved b/Package.resolved index 284ef0a6..b2a9b810 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", @@ -68,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "06b5cdc432e93b60e3bdf53aff2857c6b312991a", - "version" : "600.0.0-prerelease-2024-07-30" + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" } }, { diff --git a/Package.swift b/Package.swift index 0daf6b54..c0fe99d4 100644 --- a/Package.swift +++ b/Package.swift @@ -28,9 +28,10 @@ let package = Package( .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.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( @@ -84,10 +86,11 @@ let package = Package( .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + "Mocker", "Functions", "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", @@ -112,10 +118,12 @@ let package = Package( name: "PostgRESTTests", dependencies: [ .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + "Mocker", "Helpers", "PostgREST", - ], - exclude: ["__Snapshots__"] + "TestHelpers", + ] ), .target( name: "Realtime", @@ -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( @@ -175,7 +186,9 @@ let package = Package( dependencies: [ .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), .product(name: "XCTestDynamicOverlay", package: "xctest-dynamic-overlay"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), "Auth", + "Mocker", ] ), ] diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 23ec63e8..d1380e65 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -29,6 +29,7 @@ public final class AuthClient: Sendable { private var eventEmitter: AuthStateChangeEventEmitter { Dependencies[clientID].eventEmitter } private var logger: (any SupabaseLogger)? { Dependencies[clientID].configuration.logger } private var sessionStorage: SessionStorage { Dependencies[clientID].sessionStorage } + private var pkce: PKCE { Dependencies[clientID].pkce } /// Returns the session, refreshing it if necessary. /// @@ -1310,10 +1311,10 @@ public final class AuthClient: Sendable { return (nil, nil) } - let codeVerifier = PKCE.generateCodeVerifier() + let codeVerifier = pkce.generateCodeVerifier() codeVerifierStorage.set(codeVerifier) - let codeChallenge = PKCE.generateCodeChallenge(from: codeVerifier) + let codeChallenge = pkce.generateCodeChallenge(codeVerifier) let codeChallengeMethod = codeVerifier == codeChallenge ? "plain" : "s256" return (codeChallenge, codeChallengeMethod) diff --git a/Sources/Auth/AuthMFA.swift b/Sources/Auth/AuthMFA.swift index fa5bb485..2b57c67e 100644 --- a/Sources/Auth/AuthMFA.swift +++ b/Sources/Auth/AuthMFA.swift @@ -54,6 +54,7 @@ public struct AuthMFA: Sendable { /// /// - Parameter params: The parameters for verifying the MFA factor. /// - Returns: An authentication response after verifying the factor. + @discardableResult public func verify(params: MFAVerifyParams) async throws -> AuthMFAVerifyResponse { let response: AuthMFAVerifyResponse = try await api.authorizedExecute( HTTPRequest( diff --git a/Sources/Auth/Internal/Dependencies.swift b/Sources/Auth/Internal/Dependencies.swift index ba1ce91d..9fb3a5c1 100644 --- a/Sources/Auth/Internal/Dependencies.swift +++ b/Sources/Auth/Internal/Dependencies.swift @@ -14,6 +14,7 @@ struct Dependencies: Sendable { var date: @Sendable () -> Date = { Date() } var urlOpener: URLOpener = .live + var pkce: PKCE = .live var encoder: JSONEncoder { configuration.encoder } var decoder: JSONDecoder { configuration.decoder } diff --git a/Sources/Auth/Internal/PKCE.swift b/Sources/Auth/Internal/PKCE.swift index a7edb5a2..01d7fcfb 100644 --- a/Sources/Auth/Internal/PKCE.swift +++ b/Sources/Auth/Internal/PKCE.swift @@ -1,22 +1,28 @@ import Crypto import Foundation -enum PKCE { - static func generateCodeVerifier() -> String { - let buffer = [UInt8].random(count: 64) - return Data(buffer).pkceBase64EncodedString() - } +struct PKCE { + var generateCodeVerifier: @Sendable () -> String + var generateCodeChallenge: @Sendable (_ codeVerifier: String) -> String +} - static func generateCodeChallenge(from string: String) -> String { - guard let data = string.data(using: .utf8) else { - preconditionFailure("provided string should be utf8 encoded.") - } +extension PKCE { + static let live = PKCE( + generateCodeVerifier: { + let buffer = [UInt8].random(count: 64) + return Data(buffer).pkceBase64EncodedString() + }, + generateCodeChallenge: { codeVerifier in + guard let data = codeVerifier.data(using: .utf8) else { + preconditionFailure("provided string should be utf8 encoded.") + } - var hasher = SHA256() - hasher.update(data: data) - let hashed = hasher.finalize() - return Data(hashed).pkceBase64EncodedString() - } + var hasher = SHA256() + hasher.update(data: data) + let hashed = hasher.finalize() + return Data(hashed).pkceBase64EncodedString() + } + ) } extension Data { diff --git a/Sources/Functions/FunctionsClient.swift b/Sources/Functions/FunctionsClient.swift index e9be2f71..9eda08b9 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 @@ -42,13 +43,15 @@ public final class FunctionsClient: Sendable { /// - region: The Region to invoke the functions in. /// - logger: SupabaseLogger instance to use. /// - fetch: The fetch handler used to make requests. (Default: URLSession.shared.data(for:)) + /// - sessionConfiguration: The `URLSessionConfiguration` used for making requests. @_disfavoredOverload public convenience init( url: URL, headers: [String: String] = [:], region: String? = nil, logger: (any SupabaseLogger)? = nil, - fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) } + fetch: @escaping FetchHandler = { try await URLSession.shared.data(for: $0) }, + sessionConfiguration: URLSessionConfiguration = .default ) { var interceptors: [any HTTPClientInterceptor] = [] if let logger { @@ -57,18 +60,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 +175,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 +205,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 +223,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 @@ -230,12 +244,14 @@ public final class FunctionsClient: Sendable { final class StreamResponseDelegate: NSObject, URLSessionDataDelegate, Sendable { let continuation: AsyncThrowingStream.Continuation + let lastReceivedData = LockIsolated(nil) init(continuation: AsyncThrowingStream.Continuation) { self.continuation = continuation } func urlSession(_: URLSession, dataTask _: URLSessionDataTask, didReceive data: Data) { + lastReceivedData.setValue(data) continuation.yield(data) } @@ -243,14 +259,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: lastReceivedData.value ?? Data() + ) continuation.finish(throwing: error) return } @@ -259,6 +285,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/Codable.swift b/Sources/Helpers/Codable.swift new file mode 100644 index 00000000..88a73e69 --- /dev/null +++ b/Sources/Helpers/Codable.swift @@ -0,0 +1,36 @@ +// +// Codable.swift +// Supabase +// +// Created by Guilherme Souza on 20/01/25. +// + +import ConcurrencyExtras +import Foundation + +extension JSONDecoder { + private static let supportedDateFormatters: [UncheckedSendable] = [ + ISO8601DateFormatter.iso8601WithFractionalSeconds, + ISO8601DateFormatter.iso8601, + ] + + /// Default `JSONDecoder` for decoding types from Supabase. + package static let `default`: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .custom { decoder in + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + + for formatter in supportedDateFormatters { + if let date = formatter.value.date(from: string) { + return date + } + } + + throw DecodingError.dataCorruptedError( + in: container, debugDescription: "Invalid date format: \(string)" + ) + } + return decoder + }() +} diff --git a/Sources/Helpers/HTTP/HTTPFields.swift b/Sources/Helpers/HTTP/HTTPFields.swift index a193533f..56cbdbcf 100644 --- a/Sources/Helpers/HTTP/HTTPFields.swift +++ b/Sources/Helpers/HTTP/HTTPFields.swift @@ -1,37 +1,70 @@ import HTTPTypes -package extension HTTPFields { - init(_ dictionary: [String: String]) { +extension HTTPFields { + package init(_ dictionary: [String: String]) { self.init(dictionary.map { .init(name: .init($0.key)!, value: $0.value) }) } - - var dictionary: [String: String] { + + package var dictionary: [String: String] { let keyValues = self.map { ($0.name.rawName, $0.value) } - + return .init(keyValues, uniquingKeysWith: { $1 }) } - - mutating func merge(with other: Self) { + + package mutating func merge(with other: Self) { for field in other { self[field.name] = field.value } } - - func merging(with other: Self) -> Self { + + package func merging(with other: Self) -> Self { var copy = self - + for field in other { copy[field.name] = field.value } return copy } + + /// Append or update a value in header. + /// + /// Example: + /// ```swift + /// var headers: HTTPFields = [ + /// "Prefer": "count=exact,return=representation" + /// ] + /// + /// headers.appendOrUpdate(.prefer, value: "return=minimal") + /// #expect(headers == ["Prefer": "count=exact,return=minimal"] + /// ``` + package mutating func appendOrUpdate( + _ name: HTTPField.Name, + value: String, + separator: String = "," + ) { + if let currentValue = self[name] { + var components = currentValue.components(separatedBy: separator) + + if let key = value.split(separator: "=").first, + let index = components.firstIndex(where: { $0.hasPrefix("\(key)=") }) + { + components[index] = value + } else { + components.append(value) + } + + self[name] = components.joined(separator: separator) + } else { + self[name] = value + } + } } -package extension HTTPField.Name { - static let xClientInfo = HTTPField.Name("X-Client-Info")! - static let xRegion = HTTPField.Name("x-region")! - static let xRelayError = HTTPField.Name("x-relay-error")! +extension HTTPField.Name { + package static let xClientInfo = HTTPField.Name("X-Client-Info")! + package static let xRegion = HTTPField.Name("x-region")! + package static let xRelayError = HTTPField.Name("x-relay-error")! } 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/PostgREST/PostgrestBuilder.swift b/Sources/PostgREST/PostgrestBuilder.swift index ce02e3c2..6b5d5034 100644 --- a/Sources/PostgREST/PostgrestBuilder.swift +++ b/Sources/PostgREST/PostgrestBuilder.swift @@ -106,11 +106,7 @@ public class PostgrestBuilder: @unchecked Sendable { } if let count = $0.fetchOptions.count { - if let prefer = $0.request.headers[.prefer] { - $0.request.headers[.prefer] = "\(prefer),count=\(count.rawValue)" - } else { - $0.request.headers[.prefer] = "count=\(count.rawValue)" - } + $0.request.headers.appendOrUpdate(.prefer, value: "count=\(count.rawValue)") } if $0.request.headers[.accept] == nil { diff --git a/Sources/PostgREST/PostgrestTransformBuilder.swift b/Sources/PostgREST/PostgrestTransformBuilder.swift index ead0cc0e..f0b470f8 100644 --- a/Sources/PostgREST/PostgrestTransformBuilder.swift +++ b/Sources/PostgREST/PostgrestTransformBuilder.swift @@ -23,20 +23,7 @@ public class PostgrestTransformBuilder: PostgrestBuilder, @unchecked Sendable { .joined(separator: "") mutableState.withValue { $0.request.query.appendOrUpdate(URLQueryItem(name: "select", value: cleanedColumns)) - - if let prefer = $0.request.headers[.prefer] { - var components = prefer.components(separatedBy: ",") - - if let index = components.firstIndex(where: { $0.hasPrefix("return=") }) { - components[index] = "return=representation" - } else { - components.append("return=representation") - } - - $0.request.headers[.prefer] = components.joined(separator: ",") - } else { - $0.request.headers[.prefer] = "return=representation" - } + $0.request.headers.appendOrUpdate(.prefer, value: "return=representation") } return self } @@ -64,7 +51,7 @@ public class PostgrestTransformBuilder: PostgrestBuilder, @unchecked Sendable { "\(column).\(ascending ? "asc" : "desc").\(nullsFirst ? "nullsfirst" : "nullslast")" if let existingOrderIndex, - let currentValue = $0.request.query[existingOrderIndex].value + let currentValue = $0.request.query[existingOrderIndex].value { $0.request.query[existingOrderIndex] = URLQueryItem( name: key, @@ -85,11 +72,7 @@ public class PostgrestTransformBuilder: PostgrestBuilder, @unchecked Sendable { public func limit(_ count: Int, referencedTable: String? = nil) -> PostgrestTransformBuilder { mutableState.withValue { let key = referencedTable.map { "\($0).limit" } ?? "limit" - if let index = $0.request.query.firstIndex(where: { $0.name == key }) { - $0.request.query[index] = URLQueryItem(name: key, value: "\(count)") - } else { - $0.request.query.append(URLQueryItem(name: key, value: "\(count)")) - } + $0.request.query.appendOrUpdate(URLQueryItem(name: key, value: "\(count)")) } return self } @@ -113,24 +96,10 @@ public class PostgrestTransformBuilder: PostgrestBuilder, @unchecked Sendable { let keyLimit = referencedTable.map { "\($0).limit" } ?? "limit" mutableState.withValue { - if let index = $0.request.query.firstIndex(where: { $0.name == keyOffset }) { - $0.request.query[index] = URLQueryItem(name: keyOffset, value: "\(from)") - } else { - $0.request.query.append(URLQueryItem(name: keyOffset, value: "\(from)")) - } + $0.request.query.appendOrUpdate(URLQueryItem(name: keyOffset, value: "\(from)")) // Range is inclusive, so add 1 - if let index = $0.request.query.firstIndex(where: { $0.name == keyLimit }) { - $0.request.query[index] = URLQueryItem( - name: keyLimit, - value: "\(to - from + 1)" - ) - } else { - $0.request.query.append(URLQueryItem( - name: keyLimit, - value: "\(to - from + 1)" - )) - } + $0.request.query.appendOrUpdate(URLQueryItem(name: keyLimit, value: "\(to - from + 1)")) } return self @@ -195,7 +164,8 @@ public class PostgrestTransformBuilder: PostgrestBuilder, @unchecked Sendable { .compactMap { $0 } .joined(separator: "|") let forMediaType = $0.request.headers[.accept] ?? "application/json" - $0.request.headers[.accept] = "application/vnd.pgrst.plan+\"\(format)\"; for=\(forMediaType); options=\(options);" + $0.request.headers[.accept] = + "application/vnd.pgrst.plan+\"\(format)\"; for=\(forMediaType); options=\(options);" } return self diff --git a/Sources/PostgREST/URLQueryRepresentable.swift b/Sources/PostgREST/URLQueryRepresentable.swift index 0a98be90..e3d04823 100644 --- a/Sources/PostgREST/URLQueryRepresentable.swift +++ b/Sources/PostgREST/URLQueryRepresentable.swift @@ -68,20 +68,15 @@ extension Optional: URLQueryRepresentable where Wrapped: URLQueryRepresentable { extension JSONObject: URLQueryRepresentable { public var queryValue: String { let value = mapValues(\.value) - return JSONSerialization.stringfy(value) + return JSONSerialization.stringfy(value)! } } extension JSONSerialization { - static func stringfy(_ object: Any) -> String { - guard - let data = try? data( - withJSONObject: object, options: [.withoutEscapingSlashes, .sortedKeys] - ), - let string = String(data: data, encoding: .utf8) - else { - return "{}" - } - return string + static func stringfy(_ object: Any) -> String? { + let data = try? data( + withJSONObject: object, options: [.withoutEscapingSlashes, .sortedKeys] + ) + return data.flatMap { String(data: $0, encoding: .utf8) } } } diff --git a/Sources/Realtime/V2/CallbackManager.swift b/Sources/Realtime/CallbackManager.swift similarity index 100% rename from Sources/Realtime/V2/CallbackManager.swift rename to Sources/Realtime/CallbackManager.swift diff --git a/Sources/Realtime/Defaults.swift b/Sources/Realtime/Deprecated/Defaults.swift similarity index 100% rename from Sources/Realtime/Defaults.swift rename to Sources/Realtime/Deprecated/Defaults.swift diff --git a/Sources/Realtime/Delegated.swift b/Sources/Realtime/Deprecated/Delegated.swift similarity index 100% rename from Sources/Realtime/Delegated.swift rename to Sources/Realtime/Deprecated/Delegated.swift diff --git a/Sources/Realtime/Deprecated.swift b/Sources/Realtime/Deprecated/Deprecated.swift similarity index 100% rename from Sources/Realtime/Deprecated.swift rename to Sources/Realtime/Deprecated/Deprecated.swift diff --git a/Sources/Realtime/HeartbeatTimer.swift b/Sources/Realtime/Deprecated/HeartbeatTimer.swift similarity index 100% rename from Sources/Realtime/HeartbeatTimer.swift rename to Sources/Realtime/Deprecated/HeartbeatTimer.swift diff --git a/Sources/Realtime/PhoenixTransport.swift b/Sources/Realtime/Deprecated/PhoenixTransport.swift similarity index 100% rename from Sources/Realtime/PhoenixTransport.swift rename to Sources/Realtime/Deprecated/PhoenixTransport.swift diff --git a/Sources/Realtime/Presence.swift b/Sources/Realtime/Deprecated/Presence.swift similarity index 100% rename from Sources/Realtime/Presence.swift rename to Sources/Realtime/Deprecated/Presence.swift diff --git a/Sources/Realtime/Push.swift b/Sources/Realtime/Deprecated/Push.swift similarity index 100% rename from Sources/Realtime/Push.swift rename to Sources/Realtime/Deprecated/Push.swift diff --git a/Sources/Realtime/RealtimeChannel.swift b/Sources/Realtime/Deprecated/RealtimeChannel.swift similarity index 100% rename from Sources/Realtime/RealtimeChannel.swift rename to Sources/Realtime/Deprecated/RealtimeChannel.swift diff --git a/Sources/Realtime/RealtimeClient.swift b/Sources/Realtime/Deprecated/RealtimeClient.swift similarity index 100% rename from Sources/Realtime/RealtimeClient.swift rename to Sources/Realtime/Deprecated/RealtimeClient.swift diff --git a/Sources/Realtime/RealtimeMessage.swift b/Sources/Realtime/Deprecated/RealtimeMessage.swift similarity index 100% rename from Sources/Realtime/RealtimeMessage.swift rename to Sources/Realtime/Deprecated/RealtimeMessage.swift diff --git a/Sources/Realtime/TimeoutTimer.swift b/Sources/Realtime/Deprecated/TimeoutTimer.swift similarity index 100% rename from Sources/Realtime/TimeoutTimer.swift rename to Sources/Realtime/Deprecated/TimeoutTimer.swift diff --git a/Sources/Realtime/V2/PostgresAction.swift b/Sources/Realtime/PostgresAction.swift similarity index 100% rename from Sources/Realtime/V2/PostgresAction.swift rename to Sources/Realtime/PostgresAction.swift diff --git a/Sources/Realtime/V2/PostgresActionData.swift b/Sources/Realtime/PostgresActionData.swift similarity index 100% rename from Sources/Realtime/V2/PostgresActionData.swift rename to Sources/Realtime/PostgresActionData.swift diff --git a/Sources/Realtime/V2/PresenceAction.swift b/Sources/Realtime/PresenceAction.swift similarity index 100% rename from Sources/Realtime/V2/PresenceAction.swift rename to Sources/Realtime/PresenceAction.swift diff --git a/Sources/Realtime/V2/PushV2.swift b/Sources/Realtime/PushV2.swift similarity index 100% rename from Sources/Realtime/V2/PushV2.swift rename to Sources/Realtime/PushV2.swift diff --git a/Sources/Realtime/V2/RealtimeChannelV2.swift b/Sources/Realtime/RealtimeChannelV2.swift similarity index 100% rename from Sources/Realtime/V2/RealtimeChannelV2.swift rename to Sources/Realtime/RealtimeChannelV2.swift diff --git a/Sources/Realtime/V2/RealtimeClientV2.swift b/Sources/Realtime/RealtimeClientV2.swift similarity index 100% rename from Sources/Realtime/V2/RealtimeClientV2.swift rename to Sources/Realtime/RealtimeClientV2.swift diff --git a/Sources/Realtime/V2/RealtimeJoinConfig.swift b/Sources/Realtime/RealtimeJoinConfig.swift similarity index 100% rename from Sources/Realtime/V2/RealtimeJoinConfig.swift rename to Sources/Realtime/RealtimeJoinConfig.swift diff --git a/Sources/Realtime/V2/RealtimeMessageV2.swift b/Sources/Realtime/RealtimeMessageV2.swift similarity index 100% rename from Sources/Realtime/V2/RealtimeMessageV2.swift rename to Sources/Realtime/RealtimeMessageV2.swift diff --git a/Sources/Realtime/V2/Types.swift b/Sources/Realtime/Types.swift similarity index 100% rename from Sources/Realtime/V2/Types.swift rename to Sources/Realtime/Types.swift diff --git a/Sources/Realtime/WebSocket/URLSessionWebSocket.swift b/Sources/Realtime/WebSocket/URLSessionWebSocket.swift index e9a7cc9e..62bcdbc7 100644 --- a/Sources/Realtime/WebSocket/URLSessionWebSocket.swift +++ b/Sources/Realtime/WebSocket/URLSessionWebSocket.swift @@ -130,10 +130,11 @@ final class URLSessionWebSocket: WebSocket { } private func _scheduleReceive() { - _task.receive { [weak self] result in + Task { + let result = await Result { try await _task.receive() } switch result { - case .success(let value): self?._handleMessage(value) - case .failure(let error): self?._closeConnectionWithError(error) + case .success(let value): _handleMessage(value) + case .failure(let error): _closeConnectionWithError(error) } } } @@ -168,9 +169,11 @@ final class URLSessionWebSocket: WebSocket { return } - _task.send(.string(text)) { [weak self] error in - if let error { - self?._closeConnectionWithError(error) + Task { + do { + try await _task.send(.string(text)) + } catch { + _closeConnectionWithError(error) } } } @@ -198,9 +201,11 @@ final class URLSessionWebSocket: WebSocket { return } - _task.send(.data(binary)) { [weak self] error in - if let error { - self?._closeConnectionWithError(error) + Task { + do { + try await _task.send(.data(binary)) + } catch { + _closeConnectionWithError(error) } } } diff --git a/Sources/Storage/Codable.swift b/Sources/Storage/Codable.swift index f3f68563..54916cdf 100644 --- a/Sources/Storage/Codable.swift +++ b/Sources/Storage/Codable.swift @@ -9,34 +9,19 @@ import ConcurrencyExtras import Foundation extension JSONEncoder { + @available(*, deprecated, message: "Access to storage encoder is going to be removed.") public static let defaultStorageEncoder: JSONEncoder = { let encoder = JSONEncoder() encoder.keyEncodingStrategy = .convertToSnakeCase return encoder }() + + static let unconfiguredEncoder: JSONEncoder = .init() } extension JSONDecoder { - public static let defaultStorageDecoder: JSONDecoder = { - let decoder = JSONDecoder() - let formatter = LockIsolated(ISO8601DateFormatter()) - formatter.withValue { - $0.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - } - - decoder.dateDecodingStrategy = .custom { decoder in - let container = try decoder.singleValueContainer() - let string = try container.decode(String.self) - - if let date = formatter.withValue({ $0.date(from: string) }) { - return date - } - - throw DecodingError.dataCorruptedError( - in: container, debugDescription: "Invalid date format: \(string)" - ) - } - - return decoder - }() + @available(*, deprecated, message: "Access to storage decoder is going to be removed.") + public static var defaultStorageDecoder: JSONDecoder { + .default + } } diff --git a/Sources/Storage/Deprecated.swift b/Sources/Storage/Deprecated.swift index 865328b9..ed39b06b 100644 --- a/Sources/Storage/Deprecated.swift +++ b/Sources/Storage/Deprecated.swift @@ -11,7 +11,8 @@ extension StorageClientConfiguration { @available( *, deprecated, - message: "Replace usages of this initializer with new init(url:headers:encoder:decoder:session:logger)" + message: + "Replace usages of this initializer with new init(url:headers:encoder:decoder:session:logger)" ) public init( url: URL, @@ -101,7 +102,8 @@ extension StorageFileApi { @available( *, deprecated, - message: "File was deprecated and it isn't used in the package anymore, if you're using it on your application, consider replacing it as it will be removed on the next major release." + message: + "File was deprecated and it isn't used in the package anymore, if you're using it on your application, consider replacing it as it will be removed on the next major release." ) public struct File: Hashable, Equatable { public var name: String @@ -121,7 +123,8 @@ public struct File: Hashable, Equatable { *, deprecated, renamed: "MultipartFormData", - message: "FormData was deprecated in favor of MultipartFormData, and it isn't used in the package anymore, if you're using it on your application, consider replacing it as it will be removed on the next major release." + message: + "FormData was deprecated in favor of MultipartFormData, and it isn't used in the package anymore, if you're using it on your application, consider replacing it as it will be removed on the next major release." ) public class FormData { var files: [File] = [] diff --git a/Sources/Storage/Helpers.swift b/Sources/Storage/Helpers.swift index a8aae837..11ba43df 100644 --- a/Sources/Storage/Helpers.swift +++ b/Sources/Storage/Helpers.swift @@ -27,7 +27,7 @@ import Helpers kUTTagClassFilenameExtension, pathExtension as CFString, nil )?.takeRetainedValue(), let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() + .takeRetainedValue() { return contentType as String } @@ -43,7 +43,7 @@ import Helpers kUTTagClassFilenameExtension, pathExtension as CFString, nil )?.takeRetainedValue(), let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() + .takeRetainedValue() { return contentType as String } @@ -62,7 +62,7 @@ import Helpers kUTTagClassFilenameExtension, pathExtension as CFString, nil )?.takeRetainedValue(), let contentType = UTTypeCopyPreferredTagWithClass(id, kUTTagClassMIMEType)? - .takeRetainedValue() + .takeRetainedValue() { return contentType as String } diff --git a/Sources/Storage/StorageApi.swift b/Sources/Storage/StorageApi.swift index 838ea800..32008133 100644 --- a/Sources/Storage/StorageApi.swift +++ b/Sources/Storage/StorageApi.swift @@ -1,6 +1,6 @@ import Foundation -import Helpers import HTTPTypes +import Helpers #if canImport(FoundationNetworking) import FoundationNetworking @@ -36,7 +36,7 @@ public class StorageApi: @unchecked Sendable { let response = try await http.send(request) - guard (200 ..< 300).contains(response.statusCode) else { + guard (200..<300).contains(response.statusCode) else { if let error = try? configuration.decoder.decode( StorageError.self, from: response.data diff --git a/Sources/Storage/StorageError.swift b/Sources/Storage/StorageError.swift index c7a0d0a3..ac45eca9 100644 --- a/Sources/Storage/StorageError.swift +++ b/Sources/Storage/StorageError.swift @@ -5,7 +5,7 @@ public struct StorageError: Error, Decodable, Sendable { public var message: String public var error: String? - public init(statusCode: String?, message: String, error: String?) { + public init(statusCode: String? = nil, message: String, error: String? = nil) { self.statusCode = statusCode self.message = message self.error = error diff --git a/Sources/Storage/StorageFileApi.swift b/Sources/Storage/StorageFileApi.swift index cfcbef21..11ceaab4 100644 --- a/Sources/Storage/StorageFileApi.swift +++ b/Sources/Storage/StorageFileApi.swift @@ -70,7 +70,7 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { } private struct SignedURLResponse: Decodable { - let signedURL: URL + let signedURL: String } private func _uploadOrUpdate( @@ -274,7 +274,7 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { let transform: TransformOptions? } - let encoder = JSONEncoder() + let encoder = JSONEncoder.unconfiguredEncoder let response = try await execute( HTTPRequest( @@ -325,7 +325,7 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { let paths: [String] } - let encoder = JSONEncoder() + let encoder = JSONEncoder.unconfiguredEncoder let response = try await execute( HTTPRequest( @@ -354,25 +354,25 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { try await createSignedURLs(paths: paths, expiresIn: expiresIn, download: download ? "" : nil) } - private func makeSignedURL(_ signedURL: URL, download: String?) throws -> URL { - guard - let signedURLComponents = URLComponents( - url: signedURL, - resolvingAgainstBaseURL: false - ), - var baseURLComponents = URLComponents(url: configuration.url, resolvingAgainstBaseURL: false) + private func makeSignedURL(_ signedURL: String, download: String?) throws -> URL { + guard let signedURLComponents = URLComponents(string: signedURL), + var baseComponents = URLComponents( + url: configuration.url, resolvingAgainstBaseURL: false) else { throw URLError(.badURL) } - baseURLComponents.path += signedURLComponents.path - baseURLComponents.queryItems = signedURLComponents.queryItems ?? [] + baseComponents.path += + signedURLComponents.path.hasPrefix("/") + ? signedURLComponents.path : "/\(signedURLComponents.path)" + baseComponents.queryItems = signedURLComponents.queryItems if let download { - baseURLComponents.queryItems!.append(URLQueryItem(name: "download", value: download)) + baseComponents.queryItems = baseComponents.queryItems ?? [] + baseComponents.queryItems!.append(URLQueryItem(name: "download", value: download)) } - guard let signedURL = baseURLComponents.url else { + guard let signedURL = baseComponents.url else { throw URLError(.badURL) } @@ -402,7 +402,7 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { path: String? = nil, options: SearchOptions? = nil ) async throws -> [FileObject] { - let encoder = JSONEncoder() + let encoder = JSONEncoder.unconfiguredEncoder var options = options ?? defaultSearchOptions options.prefix = path ?? "" @@ -545,7 +545,7 @@ public class StorageFileApi: StorageApi, @unchecked Sendable { options: CreateSignedUploadURLOptions? = nil ) async throws -> SignedUploadURL { struct Response: Decodable { - let url: URL + let url: String } var headers = HTTPFields() diff --git a/Sources/TestHelpers/MockExtensions.swift b/Sources/TestHelpers/MockExtensions.swift new file mode 100644 index 00000000..b2417ea8 --- /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..69f4dc08 --- /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..7073fb5e 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", @@ -131,8 +140,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-snapshot-testing", "state" : { - "revision" : "42a086182681cf661f5c47c9b7dc3931de18c6d7", - "version" : "1.17.6" + "revision" : "2e6a85b73fc14e27d7542165ae73b1a10516ca9a", + "version" : "1.17.7" } }, { diff --git a/Tests/AuthTests/AuthClientMultipleInstancesTests.swift b/Tests/AuthTests/AuthClientMultipleInstancesTests.swift index 26998388..fe4c3231 100644 --- a/Tests/AuthTests/AuthClientMultipleInstancesTests.swift +++ b/Tests/AuthTests/AuthClientMultipleInstancesTests.swift @@ -5,10 +5,11 @@ // Created by Guilherme Souza on 05/07/24. // -@testable import Auth import TestHelpers import XCTest +@testable import Auth + final class AuthClientMultipleInstancesTests: XCTestCase { func testMultipleAuthClientInstances() { let url = URL(string: "http://localhost:54321/auth")! diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 4f154438..10dde2bf 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -9,6 +9,7 @@ import ConcurrencyExtras import CustomDump import Helpers import InlineSnapshotTesting +import Mocker import TestHelpers import XCTest @@ -35,11 +36,15 @@ final class AuthClientTests: XCTestCase { override func setUp() { super.setUp() storage = InMemoryLocalStorage() + + // isRecording = true } override func tearDown() { super.tearDown() + Mocker.removeAll() + let completion = { [weak sut] in XCTAssertNil(sut, "sut should not leak") } @@ -80,9 +85,28 @@ final class AuthClientTests: XCTestCase { } func testSignOut() async throws { - sut = makeSUT { _ in - .stub() + Mock( + url: clientURL.appendingPathComponent("logout"), + ignoreQuery: true, + statusCode: 200, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/logout?scope=global" + """# } + .register() + + sut = makeSUT() Dependencies[sut.clientID].sessionStorage.store(.validSession) @@ -109,9 +133,29 @@ final class AuthClientTests: XCTestCase { } func testSignOutWithOthersScopeShouldNotRemoveLocalSession() async throws { - sut = makeSUT { _ in - .stub() + Mock( + url: clientURL.appendingPathComponent("logout").appendingQueryItems([ + URLQueryItem(name: "scope", value: "others") + ]), + statusCode: 200, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/logout?scope=others" + """# } + .register() + + sut = makeSUT() Dependencies[sut.clientID].sessionStorage.store(.validSession) @@ -122,16 +166,29 @@ final class AuthClientTests: XCTestCase { } func testSignOutShouldRemoveSessionIfUserIsNotFound() async throws { - sut = makeSUT { _ in - throw AuthError.api( - message: "", - errorCode: .unknown, - underlyingData: Data(), - underlyingResponse: HTTPURLResponse( - url: URL(string: "http://localhost")!, statusCode: 404, httpVersion: nil, - headerFields: nil)! - ) + Mock( + url: clientURL.appendingPathComponent("logout").appendingQueryItems([ + URLQueryItem(name: "scope", value: "global") + ]), + statusCode: 404, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/logout?scope=global" + """# } + .register() + + sut = makeSUT() let validSession = Session.validSession Dependencies[sut.clientID].sessionStorage.store(validSession) @@ -155,16 +212,29 @@ final class AuthClientTests: XCTestCase { } func testSignOutShouldRemoveSessionIfJWTIsInvalid() async throws { - sut = makeSUT { _ in - throw AuthError.api( - message: "", - errorCode: .invalidCredentials, - underlyingData: Data(), - underlyingResponse: HTTPURLResponse( - url: URL(string: "http://localhost")!, statusCode: 401, httpVersion: nil, - headerFields: nil)! - ) + Mock( + url: clientURL.appendingPathComponent("logout").appendingQueryItems([ + URLQueryItem(name: "scope", value: "global") + ]), + statusCode: 401, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/logout?scope=global" + """# } + .register() + + sut = makeSUT() let validSession = Session.validSession Dependencies[sut.clientID].sessionStorage.store(validSession) @@ -188,16 +258,29 @@ final class AuthClientTests: XCTestCase { } func testSignOutShouldRemoveSessionIf403Returned() async throws { - sut = makeSUT { _ in - throw AuthError.api( - message: "", - errorCode: .invalidCredentials, - underlyingData: Data(), - underlyingResponse: HTTPURLResponse( - url: URL(string: "http://localhost")!, statusCode: 403, httpVersion: nil, - headerFields: nil)! - ) + Mock( + url: clientURL.appendingPathComponent("logout").appendingQueryItems([ + URLQueryItem(name: "scope", value: "global") + ]), + statusCode: 403, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/logout?scope=global" + """# } + .register() + + sut = makeSUT() let validSession = Session.validSession Dependencies[sut.clientID].sessionStorage.store(validSession) @@ -223,9 +306,29 @@ final class AuthClientTests: XCTestCase { func testSignInAnonymously() async throws { let session = Session(fromMockNamed: "anonymous-sign-in-response") - let sut = makeSUT { _ in - .stub(fromFileName: "anonymous-sign-in-response") + Mock( + url: clientURL.appendingPathComponent("signup"), + statusCode: 200, + data: [ + .post: MockData.anonymousSignInResponse + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 2" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{}" \ + "http://localhost:54321/auth/v1/signup" + """# } + .register() + + let sut = makeSUT() let eventsTask = Task { await sut.authStateChanges.prefix(2).collect() @@ -243,9 +346,31 @@ final class AuthClientTests: XCTestCase { } func testSignInWithOAuth() async throws { - let sut = makeSUT { _ in - .stub(fromFileName: "session") + Mock( + url: clientURL.appendingPathComponent("token").appendingQueryItems([ + URLQueryItem(name: "grant_type", value: "pkce") + ]), + statusCode: 200, + data: [ + .post: MockData.session + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 126" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"auth_code\":\"12345\",\"code_verifier\":\"nt_xCJhJXUsIlTmbE_b0r3VHDKLxFTAwXYSj1xF3ZPaulO2gejNornLLiW_C3Ru4w-5lqIh1XE2LTOsSKrj7iA\"}" \ + "http://localhost:54321/auth/v1/token?grant_type=pkce" + """# } + .register() + + let sut = makeSUT() let eventsTask = Task { await sut.authStateChanges.prefix(2).collect() @@ -266,15 +391,34 @@ final class AuthClientTests: XCTestCase { } func testGetLinkIdentityURL() async throws { - let sut = makeSUT { _ in - .stub( - """ - { - "url" : "https://github.com/login/oauth/authorize?client_id=1234&redirect_to=com.supabase.swift-examples://&redirect_uri=http://127.0.0.1:54321/auth/v1/callback&response_type=code&scope=user:email&skip_http_redirect=true&state=jwt" - } - """ - ) + let url = + "https://github.com/login/oauth/authorize?client_id=1234&redirect_to=com.supabase.swift-examples://&redirect_uri=http://127.0.0.1:54321/auth/v1/callback&response_type=code&scope=user:email&skip_http_redirect=true&state=jwt" + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("user/identities/authorize"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + { + "url": "\(url)" + } + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/user/identities/authorize?code_challenge=hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY&code_challenge_method=s256&provider=github&skip_http_redirect=true" + """# } + .register() Dependencies[sut.clientID].sessionStorage.store(.validSession) @@ -285,8 +429,7 @@ final class AuthClientTests: XCTestCase { OAuthResponse( provider: .github, url: URL( - string: - "https://github.com/login/oauth/authorize?client_id=1234&redirect_to=com.supabase.swift-examples://&redirect_uri=http://127.0.0.1:54321/auth/v1/callback&response_type=code&scope=user:email&skip_http_redirect=true&state=jwt" + string: url )! ) ) @@ -295,15 +438,33 @@ final class AuthClientTests: XCTestCase { func testLinkIdentity() async throws { let url = "https://github.com/login/oauth/authorize?client_id=1234&redirect_to=com.supabase.swift-examples://&redirect_uri=http://127.0.0.1:54321/auth/v1/callback&response_type=code&scope=user:email&skip_http_redirect=true&state=jwt" - let sut = makeSUT { _ in - .stub( - """ - { - "url" : "\(url)" - } - """ - ) + + Mock( + url: clientURL.appendingPathComponent("user/identities/authorize"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + { + "url": "\(url)" + } + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/user/identities/authorize?code_challenge=hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY&code_challenge_method=s256&provider=github&skip_http_redirect=true" + """# } + .register() + + let sut = makeSUT() Dependencies[sut.clientID].sessionStorage.store(.validSession) @@ -318,16 +479,31 @@ final class AuthClientTests: XCTestCase { } func testAdminListUsers() async throws { - let sut = makeSUT { _ in - .stub( - fromFileName: "list-users-response", - headers: [ - "X-Total-Count": "669", - "Link": - "; rel=\"next\", ; rel=\"last\"", - ] - ) + Mock( + url: clientURL.appendingPathComponent("admin/users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: MockData.listUsersResponse + ], + additionalHeaders: [ + "X-Total-Count": "669", + "Link": + "; rel=\"next\", ; rel=\"last\"", + ] + ) + .snapshotRequest { + #""" + curl \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/admin/users?page=&per_page=" + """# } + .register() + + let sut = makeSUT() let response = try await sut.admin.listUsers() XCTAssertEqual(response.total, 669) @@ -336,15 +512,30 @@ final class AuthClientTests: XCTestCase { } func testAdminListUsers_noNextPage() async throws { - let sut = makeSUT { _ in - .stub( - fromFileName: "list-users-response", - headers: [ - "X-Total-Count": "669", - "Link": "; rel=\"last\"", - ] - ) + Mock( + url: clientURL.appendingPathComponent("admin/users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: MockData.listUsersResponse + ], + additionalHeaders: [ + "X-Total-Count": "669", + "Link": "; rel=\"last\"", + ] + ) + .snapshotRequest { + #""" + curl \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/admin/users?page=&per_page=" + """# } + .register() + + let sut = makeSUT() let response = try await sut.admin.listUsers() XCTAssertEqual(response.total, 669) @@ -377,26 +568,1119 @@ final class AuthClientTests: XCTestCase { } } - private func makeSUT( - fetch: ((URLRequest) async throws -> HTTPResponse)? = nil - ) -> AuthClient { + func testSignUpWithEmailAndPassword() async throws { + Mock( + url: clientURL.appendingPathComponent("signup"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 238" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"code_challenge\":\"hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY\",\"code_challenge_method\":\"s256\",\"data\":{\"custom_key\":\"custom_value\"},\"email\":\"example@mail.com\",\"gotrue_meta_security\":{\"captcha_token\":\"dummy-captcha\"},\"password\":\"the.pass\"}" \ + "http://localhost:54321/auth/v1/signup?redirect_to=https://supabase.com" + """# + } + .register() + + let sut = makeSUT() + + try await sut.signUp( + email: "example@mail.com", + password: "the.pass", + data: ["custom_key": .string("custom_value")], + redirectTo: URL(string: "https://supabase.com"), + captchaToken: "dummy-captcha" + ) + } + + func testSignUpWithPhoneAndPassword() async throws { + Mock( + url: clientURL.appendingPathComponent("signup"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 159" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"channel\":\"sms\",\"data\":{\"custom_key\":\"custom_value\"},\"gotrue_meta_security\":{\"captcha_token\":\"dummy-captcha\"},\"password\":\"the.pass\",\"phone\":\"+1 202-918-2132\"}" \ + "http://localhost:54321/auth/v1/signup" + """# + } + .register() + + let sut = makeSUT() + + try await sut.signUp( + phone: "+1 202-918-2132", + password: "the.pass", + data: ["custom_key": .string("custom_value")], + captchaToken: "dummy-captcha" + ) + } + + func testSignInWithEmailAndPassword() async throws { + Mock( + url: clientURL.appendingPathComponent("token"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 107" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"email\":\"example@mail.com\",\"gotrue_meta_security\":{\"captcha_token\":\"dummy-captcha\"},\"password\":\"the.pass\"}" \ + "http://localhost:54321/auth/v1/token?grant_type=password" + """# + } + .register() + + let sut = makeSUT() + + try await sut.signIn( + email: "example@mail.com", + password: "the.pass", + captchaToken: "dummy-captcha" + ) + } + + func testSignInWithPhoneAndPassword() async throws { + Mock( + url: clientURL.appendingPathComponent("token"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 106" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"gotrue_meta_security\":{\"captcha_token\":\"dummy-captcha\"},\"password\":\"the.pass\",\"phone\":\"+1 202-918-2132\"}" \ + "http://localhost:54321/auth/v1/token?grant_type=password" + """# + } + .register() + + let sut = makeSUT() + + try await sut.signIn( + phone: "+1 202-918-2132", + password: "the.pass", + captchaToken: "dummy-captcha" + ) + } + + func testSignInWithIdToken() async throws { + Mock( + url: clientURL.appendingPathComponent("token"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 145" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"access_token\":\"access-token\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"id_token\":\"id-token\",\"nonce\":\"nonce\",\"provider\":\"apple\"}" \ + "http://localhost:54321/auth/v1/token?grant_type=id_token" + """# + } + .register() + + let sut = makeSUT() + + try await sut.signInWithIdToken( + credentials: OpenIDConnectCredentials( + provider: .apple, + idToken: "id-token", + accessToken: "access-token", + nonce: "nonce", + gotrueMetaSecurity: AuthMetaSecurity( + captchaToken: "captcha-token" + ) + ) + ) + } + + func testSignInWithOTPUsingEmail() async throws { + Mock( + url: clientURL.appendingPathComponent("otp"), + ignoreQuery: true, + statusCode: 200, + data: [.post: Data()] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 235" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"code_challenge\":\"hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY\",\"code_challenge_method\":\"s256\",\"create_user\":true,\"data\":{\"custom_key\":\"custom_value\"},\"email\":\"example@mail.com\",\"gotrue_meta_security\":{\"captcha_token\":\"dummy-captcha\"}}" \ + "http://localhost:54321/auth/v1/otp?redirect_to=https://supabase.com" + """# + } + .register() + + let sut = makeSUT() + + try await sut.signInWithOTP( + email: "example@mail.com", + redirectTo: URL(string: "https://supabase.com"), + shouldCreateUser: true, + data: ["custom_key": .string("custom_value")], + captchaToken: "dummy-captcha" + ) + } + + func testSignInWithOTPUsingPhone() async throws { + Mock( + url: clientURL.appendingPathComponent("otp"), + ignoreQuery: true, + statusCode: 200, + data: [.post: Data()] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 156" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"channel\":\"sms\",\"create_user\":true,\"data\":{\"custom_key\":\"custom_value\"},\"gotrue_meta_security\":{\"captcha_token\":\"dummy-captcha\"},\"phone\":\"+1 202-918-2132\"}" \ + "http://localhost:54321/auth/v1/otp" + """# + } + .register() + + let sut = makeSUT() + + try await sut.signInWithOTP( + phone: "+1 202-918-2132", + shouldCreateUser: true, + data: ["custom_key": .string("custom_value")], + captchaToken: "dummy-captcha" + ) + } + + func testGetOAuthSignInURL() async throws { + let sut = makeSUT(flowType: .implicit) + let url = try sut.getOAuthSignInURL( + provider: .github, scopes: "read,write", + redirectTo: URL(string: "https://dummy-url.com/redirect")!, + queryParams: [("extra_key", "extra_value")] + ) + XCTAssertEqual( + url, + URL( + string: + "http://localhost:54321/auth/v1/authorize?provider=github&scopes=read,write&redirect_to=https://dummy-url.com/redirect&extra_key=extra_value" + )! + ) + } + + func testRefreshSession() async throws { + Mock( + url: clientURL.appendingPathComponent("token"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 33" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"refresh_token\":\"refresh-token\"}" \ + "http://localhost:54321/auth/v1/token?grant_type=refresh_token" + """# + } + .register() + + let sut = makeSUT() + try await sut.refreshSession(refreshToken: "refresh-token") + } + + #if !os(Linux) && !os(Windows) + func testSessionFromURL() async throws { + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + statusCode: 200, + data: [.get: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --header "Authorization: bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/user" + """# + } + .register() + + let sut = makeSUT(flowType: .implicit) + + let currentDate = Date() + + Dependencies[sut.clientID].date = { currentDate } + + let url = URL( + string: + "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken&token_type=bearer" + )! + + let session = try await sut.session(from: url) + let expectedSession = Session( + accessToken: "accesstoken", + tokenType: "bearer", + expiresIn: 60, + expiresAt: currentDate.addingTimeInterval(60).timeIntervalSince1970, + refreshToken: "refreshtoken", + user: User(fromMockNamed: "user") + ) + XCTAssertEqual(session, expectedSession) + } + #endif + + func testSessionFromURLWithMissingComponent() async { + let sut = makeSUT() + + let url = URL( + string: + "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken" + )! + + do { + _ = try await sut.session(from: url) + } catch { + assertInlineSnapshot(of: error, as: .dump) { + """ + ▿ AuthError + ▿ pkceGrantCodeExchange: (3 elements) + - message: "Not a valid PKCE flow URL: https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken" + - error: Optional.none + - code: Optional.none + + """ + } + } + } + + func testSetSessionWithAFutureExpirationDate() async throws { + Mock( + url: clientURL.appendingPathComponent("user"), + statusCode: 200, + data: [.get: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --header "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjo0ODUyMTYzNTkzLCJzdWIiOiJmMzNkM2VjOS1hMmVlLTQ3YzQtODBlMS01YmQ5MTlmM2Q4YjgiLCJlbWFpbCI6ImhpQGJpbmFyeXNjcmFwaW5nLmNvIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6e30sInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.UiEhoahP9GNrBKw_OHBWyqYudtoIlZGkrjs7Qa8hU7I" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/user" + """# + } + .register() + + let sut = makeSUT() + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + let accessToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjo0ODUyMTYzNTkzLCJzdWIiOiJmMzNkM2VjOS1hMmVlLTQ3YzQtODBlMS01YmQ5MTlmM2Q4YjgiLCJlbWFpbCI6ImhpQGJpbmFyeXNjcmFwaW5nLmNvIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6e30sInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.UiEhoahP9GNrBKw_OHBWyqYudtoIlZGkrjs7Qa8hU7I" + + try await sut.setSession(accessToken: accessToken, refreshToken: "dummy-refresh-token") + } + + func testSetSessionWithAExpiredToken() async throws { + Mock( + url: clientURL.appendingPathComponent("token"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 39" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"refresh_token\":\"dummy-refresh-token\"}" \ + "http://localhost:54321/auth/v1/token?grant_type=refresh_token" + """# + } + .register() + + let sut = makeSUT() + + let accessToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjQ4NjQwMDIxLCJzdWIiOiJmMzNkM2VjOS1hMmVlLTQ3YzQtODBlMS01YmQ5MTlmM2Q4YjgiLCJlbWFpbCI6ImhpQGJpbmFyeXNjcmFwaW5nLmNvIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6e30sInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.CGr5zNE5Yltlbn_3Ms2cjSLs_AW9RKM3lxh7cTQrg0w" + + try await sut.setSession(accessToken: accessToken, refreshToken: "dummy-refresh-token") + } + + func testVerifyOTPUsingEmail() async throws { + Mock( + url: clientURL.appendingPathComponent("verify"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 121" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"email\":\"example@mail.com\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"token\":\"123456\",\"type\":\"magiclink\"}" \ + "http://localhost:54321/auth/v1/verify?redirect_to=https://supabase.com" + """# + } + .register() + + let sut = makeSUT() + + try await sut.verifyOTP( + email: "example@mail.com", + token: "123456", + type: .magiclink, + redirectTo: URL(string: "https://supabase.com"), + captchaToken: "captcha-token" + ) + } + + func testVerifyOTPUsingPhone() async throws { + Mock( + url: clientURL.appendingPathComponent("verify"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 114" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"phone\":\"+1 202-918-2132\",\"token\":\"123456\",\"type\":\"sms\"}" \ + "http://localhost:54321/auth/v1/verify" + """# + } + .register() + + let sut = makeSUT() + + try await sut.verifyOTP( + phone: "+1 202-918-2132", + token: "123456", + type: .sms, + captchaToken: "captcha-token" + ) + } + + func testVerifyOTPUsingTokenHash() async throws { + Mock( + url: clientURL.appendingPathComponent("verify"), + ignoreQuery: true, + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 39" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"token_hash\":\"abc-def\",\"type\":\"email\"}" \ + "http://localhost:54321/auth/v1/verify" + """# + } + .register() + + let sut = makeSUT() + + try await sut.verifyOTP( + tokenHash: "abc-def", + type: .email + ) + } + + func testUpdateUser() async throws { + Mock( + url: clientURL.appendingPathComponent("user"), + statusCode: 200, + data: [.put: MockData.user] + ) + .snapshotRequest { + #""" + curl \ + --request PUT \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Length: 258" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"code_challenge\":\"hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY\",\"code_challenge_method\":\"s256\",\"data\":{\"custom_key\":\"custom_value\"},\"email\":\"example@mail.com\",\"email_change_token\":\"123456\",\"nonce\":\"abcdef\",\"password\":\"another.pass\",\"phone\":\"+1 202-918-2132\"}" \ + "http://localhost:54321/auth/v1/user" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + try await sut.update( + user: UserAttributes( + email: "example@mail.com", + phone: "+1 202-918-2132", + password: "another.pass", + nonce: "abcdef", + emailChangeToken: "123456", + data: ["custom_key": .string("custom_value")] + ) + ) + } + + func testResetPasswordForEmail() async throws { + Mock( + url: clientURL.appendingPathComponent("recover"), + ignoreQuery: true, + statusCode: 200, + data: [.post: Data()] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 179" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"code_challenge\":\"hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY\",\"code_challenge_method\":\"s256\",\"email\":\"example@mail.com\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"}}" \ + "http://localhost:54321/auth/v1/recover?redirect_to=https://supabase.com" + """# + } + .register() + + let sut = makeSUT() + try await sut.resetPasswordForEmail( + "example@mail.com", + redirectTo: URL(string: "https://supabase.com"), + captchaToken: "captcha-token" + ) + } + + func testResendEmail() async throws { + Mock( + url: clientURL.appendingPathComponent("resend"), + ignoreQuery: true, + statusCode: 200, + data: [.post: Data()] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 107" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"email\":\"example@mail.com\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"type\":\"email_change\"}" \ + "http://localhost:54321/auth/v1/resend?redirect_to=https://supabase.com" + """# + } + .register() + + let sut = makeSUT() + + try await sut.resend( + email: "example@mail.com", + type: .emailChange, + emailRedirectTo: URL(string: "https://supabase.com"), + captchaToken: "captcha-token" + ) + } + + func testResendPhone() async throws { + Mock( + url: clientURL.appendingPathComponent("resend"), + ignoreQuery: true, + statusCode: 200, + data: [.post: Data(#"{"message_id": "12345"}"#.utf8)] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 106" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"phone\":\"+1 202-918-2132\",\"type\":\"phone_change\"}" \ + "http://localhost:54321/auth/v1/resend" + """# + } + .register() + + let sut = makeSUT() + + let response = try await sut.resend( + phone: "+1 202-918-2132", + type: .phoneChange, + captchaToken: "captcha-token" + ) + + XCTAssertEqual(response.messageId, "12345") + } + + func testDeleteUser() async throws { + let id = "E621E1F8-C36C-495A-93FC-0C247A3E6E5F" + + Mock( + url: clientURL.appendingPathComponent("admin/users/\(id)"), + statusCode: 204, + data: [.delete: Data()] + ) + .snapshotRequest { + #""" + curl \ + --request DELETE \ + --header "Content-Length: 28" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"should_soft_delete\":false}" \ + "http://localhost:54321/auth/v1/admin/users/E621E1F8-C36C-495A-93FC-0C247A3E6E5F" + """# + } + .register() + + let sut = makeSUT() + try await sut.admin.deleteUser(id: id) + } + + func testReauthenticate() async throws { + Mock( + url: clientURL.appendingPathComponent("reauthenticate"), + statusCode: 200, + data: [.get: Data()] + ) + .snapshotRequest { + #""" + curl \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/reauthenticate" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + try await sut.reauthenticate() + } + + func testUnlinkIdentity() async throws { + let identityId = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")! + Mock( + url: clientURL.appendingPathComponent("user/identities/\(identityId.uuidString)"), + statusCode: 204, + data: [.delete: Data()] + ) + .snapshotRequest { + #""" + curl \ + --request DELETE \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/user/identities/E621E1F8-C36C-495A-93FC-0C247A3E6E5F" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + try await sut.unlinkIdentity( + UserIdentity( + id: "5923044", + identityId: identityId, + userId: UUID(), + identityData: [:], + provider: "email", + createdAt: Date(), + lastSignInAt: Date(), + updatedAt: Date() + ) + ) + } + + func testSignInWithSSOUsingDomain() async throws { + Mock( + url: clientURL.appendingPathComponent("sso"), + ignoreQuery: true, + statusCode: 200, + data: [.post: Data(#"{"url":"https://supabase.com"}"#.utf8)] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 215" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"code_challenge\":\"hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY\",\"code_challenge_method\":\"s256\",\"domain\":\"supabase.com\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"redirect_to\":\"https:\/\/supabase.com\"}" \ + "http://localhost:54321/auth/v1/sso" + """# + } + .register() + + let sut = makeSUT() + + let response = try await sut.signInWithSSO( + domain: "supabase.com", + redirectTo: URL(string: "https://supabase.com"), + captchaToken: "captcha-token" + ) + + XCTAssertEqual(response.url, URL(string: "https://supabase.com")!) + } + + func testSignInWithSSOUsingProviderId() async throws { + Mock( + url: clientURL.appendingPathComponent("sso"), + ignoreQuery: true, + statusCode: 200, + data: [.post: Data(#"{"url":"https://supabase.com"}"#.utf8)] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Content-Length: 244" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"code_challenge\":\"hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY\",\"code_challenge_method\":\"s256\",\"gotrue_meta_security\":{\"captcha_token\":\"captcha-token\"},\"provider_id\":\"E621E1F8-C36C-495A-93FC-0C247A3E6E5F\",\"redirect_to\":\"https:\/\/supabase.com\"}" \ + "http://localhost:54321/auth/v1/sso" + """# + } + .register() + + let sut = makeSUT() + + let response = try await sut.signInWithSSO( + providerId: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F", + redirectTo: URL(string: "https://supabase.com"), + captchaToken: "captcha-token" + ) + + XCTAssertEqual(response.url, URL(string: "https://supabase.com")!) + } + + func testMFAEnrollLegacy() async throws { + Mock( + url: clientURL.appendingPathComponent("factors"), + statusCode: 200, + data: [ + .post: Data( + """ + { + "id": "12345", + "type": "totp" + } + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Length: 69" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"factor_type\":\"totp\",\"friendly_name\":\"test\",\"issuer\":\"supabase.com\"}" \ + "http://localhost:54321/auth/v1/factors" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + let response = try await sut.mfa.enroll( + params: MFAEnrollParams( + issuer: "supabase.com", + friendlyName: "test" + ) + ) + + XCTAssertEqual(response.id, "12345") + XCTAssertEqual(response.type, "totp") + } + + func testMFAEnrollTotp() async throws { + Mock( + url: clientURL.appendingPathComponent("factors"), + statusCode: 200, + data: [ + .post: Data( + """ + { + "id": "12345", + "type": "totp" + } + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Length: 69" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"factor_type\":\"totp\",\"friendly_name\":\"test\",\"issuer\":\"supabase.com\"}" \ + "http://localhost:54321/auth/v1/factors" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + let response = try await sut.mfa.enroll( + params: .totp( + issuer: "supabase.com", + friendlyName: "test" + ) + ) + + XCTAssertEqual(response.id, "12345") + XCTAssertEqual(response.type, "totp") + } + + func testMFAEnrollPhone() async throws { + Mock( + url: clientURL.appendingPathComponent("factors"), + statusCode: 200, + data: [ + .post: Data( + """ + { + "id": "12345", + "type": "phone" + } + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Length: 72" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"factor_type\":\"phone\",\"friendly_name\":\"test\",\"phone\":\"+1 202-918-2132\"}" \ + "http://localhost:54321/auth/v1/factors" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + let response = try await sut.mfa.enroll( + params: .phone( + friendlyName: "test", + phone: "+1 202-918-2132" + ) + ) + + XCTAssertEqual(response.id, "12345") + XCTAssertEqual(response.type, "phone") + } + + func testMFAChallenge() async throws { + let factorId = "123" + + Mock( + url: clientURL.appendingPathComponent("factors/\(factorId)/challenge"), + statusCode: 200, + data: [ + .post: Data( + """ + { + "id": "12345", + "type": "totp", + "expires_at": 12345678 + } + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/factors/123/challenge" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + let response = try await sut.mfa.challenge(params: .init(factorId: factorId)) + + XCTAssertEqual( + response, + AuthMFAChallengeResponse( + id: "12345", + type: "totp", + expiresAt: 12_345_678 + ) + ) + } + + func testMFAChallengeWithPhoneType() async throws { + let factorId = "123" + + Mock( + url: clientURL.appendingPathComponent("factors/\(factorId)/challenge"), + statusCode: 200, + data: [ + .post: Data( + """ + { + "id": "12345", + "type": "phone", + "expires_at": 12345678 + } + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Length: 17" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"channel\":\"sms\"}" \ + "http://localhost:54321/auth/v1/factors/123/challenge" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + let response = try await sut.mfa.challenge( + params: .init( + factorId: factorId, + channel: .sms + ) + ) + + XCTAssertEqual( + response, + AuthMFAChallengeResponse( + id: "12345", + type: "phone", + expiresAt: 12_345_678 + ) + ) + } + + func testMFAVerify() async throws { + let factorId = "123" + + Mock( + url: clientURL.appendingPathComponent("factors/\(factorId)/verify"), + statusCode: 200, + data: [.post: MockData.session] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Authorization: Bearer accesstoken" \ + --header "Content-Length: 56" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"challenge_id\":\"123\",\"code\":\"123456\",\"factor_id\":\"123\"}" \ + "http://localhost:54321/auth/v1/factors/123/verify" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + try await sut.mfa.verify( + params: .init( + factorId: factorId, + challengeId: "123", + code: "123456" + ) + ) + } + + func testMFAUnenroll() async throws { + Mock( + url: clientURL.appendingPathComponent("factors/123"), + statusCode: 204, + data: [.delete: Data(#"{"factor_id":"123"}"#.utf8)] + ) + .snapshotRequest { + #""" + curl \ + --request DELETE \ + --header "Authorization: Bearer accesstoken" \ + --header "X-Client-Info: auth-swift/0.0.0" \ + --header "X-Supabase-Api-Version: 2024-01-01" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/auth/v1/factors/123" + """# + } + .register() + + let sut = makeSUT() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + let factorId = try await sut.mfa.unenroll(params: .init(factorId: "123")).factorId + + XCTAssertEqual(factorId, "123") + } + + private func makeSUT(flowType: AuthFlowType = .pkce) -> AuthClient { + let sessionConfiguration = URLSessionConfiguration.default + sessionConfiguration.protocolClasses = [MockingURLProtocol.self] + let session = URLSession(configuration: sessionConfiguration) + + let encoder = AuthClient.Configuration.jsonEncoder + encoder.outputFormatting = [.sortedKeys] + let configuration = AuthClient.Configuration( url: clientURL, - headers: ["Apikey": "dummy.api.key"], + headers: [ + "apikey": + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" + ], + flowType: flowType, localStorage: storage, logger: nil, + encoder: encoder, fetch: { request in - guard let fetch else { - throw UnimplementedError() - } - - let response = try await fetch(request) - return (response.data, response.underlyingResponse) + try await session.data(for: request) } ) let sut = AuthClient(configuration: configuration) + Dependencies[sut.clientID].pkce.generateCodeVerifier = { + "nt_xCJhJXUsIlTmbE_b0r3VHDKLxFTAwXYSj1xF3ZPaulO2gejNornLLiW_C3Ru4w-5lqIh1XE2LTOsSKrj7iA" + } + + Dependencies[sut.clientID].pkce.generateCodeChallenge = { _ in + "hgJeigklONUI1pKSS98MIAbtJGaNu0zJU1iSiFOn2lY" + } + return sut } } @@ -450,3 +1734,18 @@ extension HTTPResponse { ) } } + +enum MockData { + static let listUsersResponse = try! Data( + contentsOf: Bundle.module.url(forResource: "list-users-response", withExtension: "json")!) + + static let session = try! Data( + contentsOf: Bundle.module.url(forResource: "session", withExtension: "json")!) + + static let user = try! Data( + contentsOf: Bundle.module.url(forResource: "user", withExtension: "json")!) + + static let anonymousSignInResponse = try! Data( + contentsOf: Bundle.module.url(forResource: "anonymous-sign-in-response", withExtension: "json")! + ) +} diff --git a/Tests/AuthTests/AuthErrorTests.swift b/Tests/AuthTests/AuthErrorTests.swift index 66695263..e630c953 100644 --- a/Tests/AuthTests/AuthErrorTests.swift +++ b/Tests/AuthTests/AuthErrorTests.swift @@ -5,9 +5,10 @@ // Created by Guilherme Souza on 29/08/24. // -@testable import Auth import XCTest +@testable import Auth + #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -26,12 +27,14 @@ final class AuthErrorTests: XCTestCase { message: "API Error", errorCode: .emailConflictIdentityNotDeletable, underlyingData: Data(), - underlyingResponse: HTTPURLResponse(url: URL(string: "http://localhost")!, statusCode: 400, httpVersion: nil, headerFields: nil)! + underlyingResponse: HTTPURLResponse( + url: URL(string: "http://localhost")!, statusCode: 400, httpVersion: nil, headerFields: nil)! ) XCTAssertEqual(api.errorCode, .emailConflictIdentityNotDeletable) XCTAssertEqual(api.message, "API Error") - let pkceGrantCodeExchange = AuthError.pkceGrantCodeExchange(message: "PKCE failure", error: nil, code: nil) + let pkceGrantCodeExchange = AuthError.pkceGrantCodeExchange( + message: "PKCE failure", error: nil, code: nil) XCTAssertEqual(pkceGrantCodeExchange.errorCode, .unknown) XCTAssertEqual(pkceGrantCodeExchange.message, "PKCE failure") diff --git a/Tests/AuthTests/ExtractParamsTests.swift b/Tests/AuthTests/ExtractParamsTests.swift index 4ced833e..817fe568 100644 --- a/Tests/AuthTests/ExtractParamsTests.swift +++ b/Tests/AuthTests/ExtractParamsTests.swift @@ -5,9 +5,10 @@ // Created by Guilherme Souza on 23/12/23. // -@testable import Auth import XCTest +@testable import Auth + final class ExtractParamsTests: XCTestCase { func testExtractParamsInQuery() { let code = UUID().uuidString @@ -25,7 +26,8 @@ final class ExtractParamsTests: XCTestCase { func testExtractParamsInBothFragmentAndQuery() { let code = UUID().uuidString - let url = URL(string: "io.supabase.flutterquickstart://login-callback/?code=\(code)#message=abc")! + let url = URL( + string: "io.supabase.flutterquickstart://login-callback/?code=\(code)#message=abc")! let params = extractParams(from: url) XCTAssertEqual(params, ["code": code, "message": "abc"]) } diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift deleted file mode 100644 index 1152c2d1..00000000 --- a/Tests/AuthTests/RequestsTests.swift +++ /dev/null @@ -1,555 +0,0 @@ -// -// RequestsTests.swift -// -// -// Created by Guilherme Souza on 07/10/23. -// - -import Helpers -import InlineSnapshotTesting -import SnapshotTesting -import TestHelpers -import XCTest - -@testable import Auth - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -struct UnimplementedError: Error {} - -final class RequestsTests: XCTestCase { - func testSignUpWithEmailAndPassword() async { - let sut = makeSUT() - - await assert { - try await sut.signUp( - email: "example@mail.com", - password: "the.pass", - data: ["custom_key": .string("custom_value")], - redirectTo: URL(string: "https://supabase.com"), - captchaToken: "dummy-captcha" - ) - } - } - - func testSignUpWithPhoneAndPassword() async { - let sut = makeSUT() - - await assert { - try await sut.signUp( - phone: "+1 202-918-2132", - password: "the.pass", - data: ["custom_key": .string("custom_value")], - captchaToken: "dummy-captcha" - ) - } - } - - func testSignInWithEmailAndPassword() async { - let sut = makeSUT() - - await assert { - try await sut.signIn( - email: "example@mail.com", - password: "the.pass", - captchaToken: "dummy-captcha" - ) - } - } - - func testSignInWithPhoneAndPassword() async { - let sut = makeSUT() - - await assert { - try await sut.signIn( - phone: "+1 202-918-2132", - password: "the.pass", - captchaToken: "dummy-captcha" - ) - } - } - - func testSignInWithIdToken() async { - let sut = makeSUT() - - await assert { - try await sut.signInWithIdToken( - credentials: OpenIDConnectCredentials( - provider: .apple, - idToken: "id-token", - accessToken: "access-token", - nonce: "nonce", - gotrueMetaSecurity: AuthMetaSecurity( - captchaToken: "captcha-token" - ) - ) - ) - } - } - - func testSignInWithOTPUsingEmail() async { - let sut = makeSUT() - - await assert { - try await sut.signInWithOTP( - email: "example@mail.com", - redirectTo: URL(string: "https://supabase.com"), - shouldCreateUser: true, - data: ["custom_key": .string("custom_value")], - captchaToken: "dummy-captcha" - ) - } - } - - func testSignInWithOTPUsingPhone() async { - let sut = makeSUT() - - await assert { - try await sut.signInWithOTP( - phone: "+1 202-918-2132", - shouldCreateUser: true, - data: ["custom_key": .string("custom_value")], - captchaToken: "dummy-captcha" - ) - } - } - - func testGetOAuthSignInURL() async throws { - let sut = makeSUT() - let url = try sut.getOAuthSignInURL( - provider: .github, scopes: "read,write", - redirectTo: URL(string: "https://dummy-url.com/redirect")!, - queryParams: [("extra_key", "extra_value")] - ) - XCTAssertEqual( - url, - URL( - string: - "http://localhost:54321/auth/v1/authorize?provider=github&scopes=read,write&redirect_to=https://dummy-url.com/redirect&extra_key=extra_value" - )! - ) - } - - func testRefreshSession() async { - let sut = makeSUT() - await assert { - try await sut.refreshSession(refreshToken: "refresh-token") - } - } - - #if !os(Linux) && !os(Windows) - func testSessionFromURL() async throws { - let sut = makeSUT(fetch: { request in - let authorizationHeader = request.allHTTPHeaderFields?["Authorization"] - XCTAssertEqual(authorizationHeader, "bearer accesstoken") - return (json(named: "user"), HTTPURLResponse.stub()) - }) - - let currentDate = Date() - - Dependencies[sut.clientID].date = { currentDate } - - let url = URL( - string: - "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken&token_type=bearer" - )! - - let session = try await sut.session(from: url) - let expectedSession = Session( - accessToken: "accesstoken", - tokenType: "bearer", - expiresIn: 60, - expiresAt: currentDate.addingTimeInterval(60).timeIntervalSince1970, - refreshToken: "refreshtoken", - user: User(fromMockNamed: "user") - ) - XCTAssertEqual(session, expectedSession) - } - #endif - - func testSessionFromURLWithMissingComponent() async { - let sut = makeSUT() - - let url = URL( - string: - "https://dummy-url.com/callback#access_token=accesstoken&expires_in=60&refresh_token=refreshtoken" - )! - - do { - _ = try await sut.session(from: url) - } catch { - assertInlineSnapshot(of: error, as: .dump) { - """ - ▿ AuthError - ▿ implicitGrantRedirect: (1 element) - - message: "No session defined in URL" - - """ - } - } - } - - func testSetSessionWithAFutureExpirationDate() async throws { - let sut = makeSUT() - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - let accessToken = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjo0ODUyMTYzNTkzLCJzdWIiOiJmMzNkM2VjOS1hMmVlLTQ3YzQtODBlMS01YmQ5MTlmM2Q4YjgiLCJlbWFpbCI6ImhpQGJpbmFyeXNjcmFwaW5nLmNvIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6e30sInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.UiEhoahP9GNrBKw_OHBWyqYudtoIlZGkrjs7Qa8hU7I" - - await assert { - try await sut.setSession(accessToken: accessToken, refreshToken: "dummy-refresh-token") - } - } - - func testSetSessionWithAExpiredToken() async throws { - let sut = makeSUT() - - let accessToken = - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNjQ4NjQwMDIxLCJzdWIiOiJmMzNkM2VjOS1hMmVlLTQ3YzQtODBlMS01YmQ5MTlmM2Q4YjgiLCJlbWFpbCI6ImhpQGJpbmFyeXNjcmFwaW5nLmNvIiwicGhvbmUiOiIiLCJhcHBfbWV0YWRhdGEiOnsicHJvdmlkZXIiOiJlbWFpbCIsInByb3ZpZGVycyI6WyJlbWFpbCJdfSwidXNlcl9tZXRhZGF0YSI6e30sInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.CGr5zNE5Yltlbn_3Ms2cjSLs_AW9RKM3lxh7cTQrg0w" - - await assert { - try await sut.setSession(accessToken: accessToken, refreshToken: "dummy-refresh-token") - } - } - - func testSignOut() async throws { - let sut = makeSUT() - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - try await sut.signOut() - } - } - - func testSignOutWithLocalScope() async throws { - let sut = makeSUT() - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - try await sut.signOut(scope: .local) - } - } - - func testSignOutWithOthersScope() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - try await sut.signOut(scope: .others) - } - } - - func testVerifyOTPUsingEmail() async { - let sut = makeSUT() - - await assert { - try await sut.verifyOTP( - email: "example@mail.com", - token: "123456", - type: .magiclink, - redirectTo: URL(string: "https://supabase.com"), - captchaToken: "captcha-token" - ) - } - } - - func testVerifyOTPUsingPhone() async { - let sut = makeSUT() - - await assert { - try await sut.verifyOTP( - phone: "+1 202-918-2132", - token: "123456", - type: .sms, - captchaToken: "captcha-token" - ) - } - } - - func testVerifyOTPUsingTokenHash() async { - let sut = makeSUT() - - await assert { - try await sut.verifyOTP( - tokenHash: "abc-def", - type: .email - ) - } - } - - func testUpdateUser() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - try await sut.update( - user: UserAttributes( - email: "example@mail.com", - phone: "+1 202-918-2132", - password: "another.pass", - nonce: "abcdef", - emailChangeToken: "123456", - data: ["custom_key": .string("custom_value")] - ) - ) - } - } - - func testResetPasswordForEmail() async { - let sut = makeSUT() - await assert { - try await sut.resetPasswordForEmail( - "example@mail.com", - redirectTo: URL(string: "https://supabase.com"), - captchaToken: "captcha-token" - ) - } - } - - func testResendEmail() async { - let sut = makeSUT() - - await assert { - try await sut.resend( - email: "example@mail.com", - type: .emailChange, - emailRedirectTo: URL(string: "https://supabase.com"), - captchaToken: "captcha-token" - ) - } - } - - func testResendPhone() async { - let sut = makeSUT() - - await assert { - try await sut.resend( - phone: "+1 202-918-2132", - type: .phoneChange, - captchaToken: "captcha-token" - ) - } - } - - func testDeleteUser() async { - let sut = makeSUT() - - let id = "E621E1F8-C36C-495A-93FC-0C247A3E6E5F" - await assert { - try await sut.admin.deleteUser(id: id) - } - } - - func testReauthenticate() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - try await sut.reauthenticate() - } - } - - func testUnlinkIdentity() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - try await sut.unlinkIdentity( - UserIdentity( - id: "5923044", - identityId: UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!, - userId: UUID(), - identityData: [:], - provider: "email", - createdAt: Date(), - lastSignInAt: Date(), - updatedAt: Date() - ) - ) - } - } - - func testSignInWithSSOUsingDomain() async { - let sut = makeSUT() - - await assert { - _ = try await sut.signInWithSSO( - domain: "supabase.com", - redirectTo: URL(string: "https://supabase.com"), - captchaToken: "captcha-token" - ) - } - } - - func testSignInWithSSOUsingProviderId() async { - let sut = makeSUT() - - await assert { - _ = try await sut.signInWithSSO( - providerId: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F", - redirectTo: URL(string: "https://supabase.com"), - captchaToken: "captcha-token" - ) - } - } - - func testSignInAnonymously() async { - let sut = makeSUT() - - await assert { - try await sut.signInAnonymously( - data: ["custom_key": .string("custom_value")], - captchaToken: "captcha-token" - ) - } - } - - func testGetLinkIdentityURL() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - _ = try await sut.getLinkIdentityURL( - provider: .github, - scopes: "user:email", - redirectTo: URL(string: "https://supabase.com"), - queryParams: [("extra_key", "extra_value")] - ) - } - } - - func testMFAEnrollLegacy() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - _ = try await sut.mfa.enroll( - params: MFAEnrollParams(issuer: "supabase.com", friendlyName: "test")) - } - } - - func testMFAEnrollTotp() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - _ = try await sut.mfa.enroll(params: .totp(issuer: "supabase.com", friendlyName: "test")) - } - } - - func testMFAEnrollPhone() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - _ = try await sut.mfa.enroll(params: .phone(friendlyName: "test", phone: "+1 202-918-2132")) - } - } - - func testMFAChallenge() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - _ = try await sut.mfa.challenge(params: .init(factorId: "123")) - } - } - - func testMFAChallengePhone() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - _ = try await sut.mfa.challenge(params: .init(factorId: "123", channel: .whatsapp)) - } - } - - func testMFAVerify() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - _ = try await sut.mfa.verify( - params: .init(factorId: "123", challengeId: "123", code: "123456")) - } - } - - func testMFAUnenroll() async throws { - let sut = makeSUT() - - Dependencies[sut.clientID].sessionStorage.store(.validSession) - - await assert { - _ = try await sut.mfa.unenroll(params: .init(factorId: "123")) - } - } - - private func assert(_ block: () async throws -> Void) async { - do { - try await block() - } catch is UnimplementedError { - } catch { - XCTFail("Unexpected error: \(error)") - } - } - - private func makeSUT( - record: Bool = false, - flowType: AuthFlowType = .implicit, - fetch: AuthClient.FetchHandler? = nil, - file: StaticString = #file, - testName: String = #function, - line: UInt = #line - ) -> AuthClient { - let encoder = AuthClient.Configuration.jsonEncoder - encoder.outputFormatting = .sortedKeys - - let configuration = AuthClient.Configuration( - url: clientURL, - headers: ["Apikey": "dummy.api.key", "X-Client-Info": "gotrue-swift/x.y.z"], - flowType: flowType, - localStorage: InMemoryLocalStorage(), - logger: nil, - encoder: encoder, - fetch: { request in - DispatchQueue.main.sync { - assertSnapshot( - of: request, as: .curl, record: record, file: file, testName: testName, line: line - ) - } - - if let fetch { - return try await fetch(request) - } - - throw UnimplementedError() - } - ) - - return AuthClient(configuration: configuration) - } -} - -extension HTTPURLResponse { - fileprivate static func stub(code: Int = 200) -> HTTPURLResponse { - HTTPURLResponse( - url: clientURL, - statusCode: code, - httpVersion: nil, - headerFields: nil - )! - } -} diff --git a/Tests/AuthTests/StoredSessionTests.swift b/Tests/AuthTests/StoredSessionTests.swift index 9e466ec2..47a3592d 100644 --- a/Tests/AuthTests/StoredSessionTests.swift +++ b/Tests/AuthTests/StoredSessionTests.swift @@ -1,9 +1,10 @@ -@testable import Auth import ConcurrencyExtras import SnapshotTesting import TestHelpers import XCTest +@testable import Auth + final class StoredSessionTests: XCTestCase { let clientID = AuthClientID() @@ -37,11 +38,11 @@ final class StoredSessionTests: XCTestCase { appMetadata: [ "provider": "email", "providers": [ - "email", + "email" ], ], userMetadata: [ - "referrer_id": nil, + "referrer_id": nil ], aud: "authenticated", confirmationSentAt: ISO8601DateFormatter().date(from: "2022-04-09T11:57:01Z")!, @@ -65,13 +66,13 @@ final class StoredSessionTests: XCTestCase { identityId: UUID(uuidString: "859F402D-B3DE-4105-A1B9-932836D9193B")!, userId: UUID(uuidString: "859F402D-B3DE-4105-A1B9-932836D9193B")!, identityData: [ - "sub": "859f402d-b3de-4105-a1b9-932836d9193b", + "sub": "859f402d-b3de-4105-a1b9-932836d9193b" ], provider: "email", createdAt: ISO8601DateFormatter().date(from: "2022-04-09T11:57:01Z")!, lastSignInAt: ISO8601DateFormatter().date(from: "2022-04-09T11:57:01Z")!, updatedAt: ISO8601DateFormatter().date(from: "2022-04-09T11:57:01Z")! - ), + ) ], factors: nil ) 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/FunctionsTests/RequestTests.swift b/Tests/FunctionsTests/RequestTests.swift deleted file mode 100644 index 30d8bab1..00000000 --- a/Tests/FunctionsTests/RequestTests.swift +++ /dev/null @@ -1,65 +0,0 @@ -// -// RequestTests.swift -// -// -// Created by Guilherme Souza on 23/04/24. -// - -@testable import Functions -import SnapshotTesting -import XCTest - -final class RequestTests: XCTestCase { - let url = URL(string: "http://localhost:5432/functions/v1")! - let apiKey = "supabase.anon.key" - - func testInvokeWithDefaultOptions() async { - await snapshot { - try await $0.invoke("hello-world") - } - } - - func testInvokeWithCustomMethod() async { - await snapshot { - try await $0.invoke("hello-world", options: .init(method: .patch)) - } - } - - func testInvokeWithCustomRegion() async { - await snapshot { - try await $0.invoke("hello-world", options: .init(region: .apNortheast1)) - } - } - - func testInvokeWithCustomHeader() async { - await snapshot { - try await $0.invoke("hello-world", options: .init(headers: ["x-custom-key": "custom value"])) - } - } - - func testInvokeWithBody() async { - await snapshot { - try await $0.invoke("hello-world", options: .init(body: ["name": "Supabase"])) - } - } - - func snapshot( - record: Bool = false, - _ test: (FunctionsClient) async throws -> Void, - file: StaticString = #file, - testName: String = #function, - line: UInt = #line - ) async { - let sut = FunctionsClient( - url: url, - headers: ["apikey": apiKey, "x-client-info": "functions-swift/x.y.z"] - ) { request in - await MainActor.run { - assertSnapshot(of: request, as: .curl, record: record, file: file, testName: testName, line: line) - } - throw NSError(domain: "Error", code: 0, userInfo: nil) - } - - try? await test(sut) - } -} diff --git a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithBody.1.txt b/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithBody.1.txt deleted file mode 100644 index a8f7bbe3..00000000 --- a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithBody.1.txt +++ /dev/null @@ -1,7 +0,0 @@ -curl \ - --request POST \ - --header "Content-Type: application/json" \ - --header "apikey: supabase.anon.key" \ - --header "x-client-info: functions-swift/x.y.z" \ - --data "{\"name\":\"Supabase\"}" \ - "http://localhost:5432/functions/v1/hello-world" \ No newline at end of file diff --git a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomHeader.1.txt b/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomHeader.1.txt deleted file mode 100644 index 3efebb9b..00000000 --- a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomHeader.1.txt +++ /dev/null @@ -1,6 +0,0 @@ -curl \ - --request POST \ - --header "apikey: supabase.anon.key" \ - --header "x-client-info: functions-swift/x.y.z" \ - --header "x-custom-key: custom value" \ - "http://localhost:5432/functions/v1/hello-world" \ No newline at end of file diff --git a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomMethod.1.txt b/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomMethod.1.txt deleted file mode 100644 index a4460b69..00000000 --- a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomMethod.1.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --request PATCH \ - --header "apikey: supabase.anon.key" \ - --header "x-client-info: functions-swift/x.y.z" \ - "http://localhost:5432/functions/v1/hello-world" \ No newline at end of file diff --git a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt b/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt deleted file mode 100644 index b7ebf5c7..00000000 --- a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithCustomRegion.1.txt +++ /dev/null @@ -1,6 +0,0 @@ -curl \ - --request POST \ - --header "apikey: supabase.anon.key" \ - --header "x-client-info: functions-swift/x.y.z" \ - --header "x-region: ap-northeast-1" \ - "http://localhost:5432/functions/v1/hello-world" \ No newline at end of file diff --git a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithDefaultOptions.1.txt b/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithDefaultOptions.1.txt deleted file mode 100644 index 053472bb..00000000 --- a/Tests/FunctionsTests/__Snapshots__/RequestTests/testInvokeWithDefaultOptions.1.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --request POST \ - --header "apikey: supabase.anon.key" \ - --header "x-client-info: functions-swift/x.y.z" \ - "http://localhost:5432/functions/v1/hello-world" \ No newline at end of file diff --git a/Tests/IntegrationTests/AuthClientIntegrationTests.swift b/Tests/IntegrationTests/AuthClientIntegrationTests.swift index d72637c8..2bd48980 100644 --- a/Tests/IntegrationTests/AuthClientIntegrationTests.swift +++ b/Tests/IntegrationTests/AuthClientIntegrationTests.swift @@ -5,12 +5,13 @@ // Created by Guilherme Souza on 27/03/24. // -@testable import Auth import ConcurrencyExtras import CustomDump import TestHelpers import XCTest +@testable import Auth + #if canImport(FoundationNetworking) import FoundationNetworking #endif @@ -55,7 +56,7 @@ final class AuthClientIntegrationTests: XCTestCase { let password = mockPassword() let metadata: [String: AnyJSON] = [ - "test": .integer(42), + "test": .integer(42) ] let response = try await authClient.signUp( @@ -74,24 +75,6 @@ final class AuthClientIntegrationTests: XCTestCase { } } -// func testSignUpAndSignInWithPhone() async throws { -// try await XCTAssertAuthChangeEvents([.initialSession, .signedIn, .signedOut, .signedIn]) { -// let phone = mockPhoneNumber() -// let password = mockPassword() -// let metadata: [String: AnyJSON] = [ -// "test": .integer(42), -// ] -// let response = try await authClient.signUp(phone: phone, password: password, data: metadata) -// XCTAssertNotNil(response.session) -// XCTAssertEqual(response.user.phone, phone) -// XCTAssertEqual(response.user.userMetadata["test"], 42) -// -// try await authClient.signOut() -// -// try await authClient.signIn(phone: phone, password: password) -// } -// } - func testSignInWithEmail_invalidEmail() async throws { let email = mockEmail() let password = mockPassword() @@ -108,13 +91,6 @@ final class AuthClientIntegrationTests: XCTestCase { } } -// func testSignInWithOTP_usingEmail() async throws { -// let email = mockEmail() -// -// try await authClient.signInWithOTP(email: email) -// try await authClient.verifyOTP(email: email, token: "123456", type: .magiclink) -// } - func testSignOut_otherScope_shouldSignOutLocally() async throws { try await XCTAssertAuthChangeEvents([.initialSession, .signedIn]) { let email = mockEmail() @@ -170,7 +146,7 @@ final class AuthClientIntegrationTests: XCTestCase { func testUserIdentities() async throws { let session = try await signUpIfNeededOrSignIn(email: mockEmail(), password: mockPassword()) let identities = try await authClient.userIdentities() - expectNoDifference(session.user.identities, identities) + expectNoDifference(session.user.identities?.map(\.id), identities.map(\.id)) } func testUnlinkIdentity_withOnlyOneIdentity() async throws { @@ -291,10 +267,10 @@ final class AuthClientIntegrationTests: XCTestCase { } } - private func mockEmail(length: Int = Int.random(in: 5 ... 10)) -> String { + private func mockEmail(length: Int = Int.random(in: 5...10)) -> String { var username = "" - for _ in 0 ..< length { - let randomAscii = Int.random(in: 97 ... 122) // ASCII values for lowercase letters + for _ in 0.. String { // Generate random country code (1 to 3 digits) - let countryCode = String(format: "%d", Int.random(in: 1 ... 999)) + let countryCode = String(format: "%d", Int.random(in: 1...999)) // Generate random area code (3 digits) - let areaCode = String(format: "%03d", Int.random(in: 100 ... 999)) + let areaCode = String(format: "%03d", Int.random(in: 100...999)) // Generate random subscriber number (7 digits) - let subscriberNumber = String(format: "%07d", Int.random(in: 1000000 ... 9999999)) + let subscriberNumber = String(format: "%07d", Int.random(in: 1_000_000...9_999_999)) // Format the phone number in E.164 format let phoneNumber = "\(countryCode)\(areaCode)\(subscriberNumber)" @@ -322,12 +298,13 @@ final class AuthClientIntegrationTests: XCTestCase { "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+" var password = "" - for _ in 0 ..< length { - let randomIndex = Int.random(in: 0 ..< allowedCharacters.count) - let character = allowedCharacters[allowedCharacters.index( - allowedCharacters.startIndex, - offsetBy: randomIndex - )] + for _ in 0..() let client = SupabaseClient( - supabaseURL: URL(string: DotEnv.SUPABASE_URL) ?? URL(string: "http://localhost:54321")!, - supabaseKey: DotEnv.SUPABASE_ANON_KEY + supabaseURL: URL(string: DotEnv.SUPABASE_URL)!, + supabaseKey: DotEnv.SUPABASE_ANON_KEY, + options: SupabaseClientOptions( + auth: SupabaseClientOptions.AuthOptions( + storage: InMemoryLocalStorage() + ) + ) ) override func setUp() { diff --git a/Tests/IntegrationTests/StorageFileIntegrationTests.swift b/Tests/IntegrationTests/StorageFileIntegrationTests.swift index 188090a9..a284e0b5 100644 --- a/Tests/IntegrationTests/StorageFileIntegrationTests.swift +++ b/Tests/IntegrationTests/StorageFileIntegrationTests.swift @@ -5,10 +5,10 @@ // Created by Guilherme Souza on 07/05/24. // +import Helpers import InlineSnapshotTesting import Storage import XCTest -import Helpers #if canImport(FoundationNetworking) import FoundationNetworking @@ -382,7 +382,7 @@ final class StorageFileIntegrationTests: XCTestCase { let httpResponse = try XCTUnwrap(response as? HTTPURLResponse) let cacheControl = try XCTUnwrap(httpResponse.value(forHTTPHeaderField: "cache-control")) - XCTAssertEqual(cacheControl, "public, max-age=14400") + XCTAssertEqual(cacheControl, "max-age=14400") } func testUploadWithFileURL() async throws { diff --git a/Tests/IntegrationTests/supabase/.gitignore b/Tests/IntegrationTests/supabase/.gitignore new file mode 100644 index 00000000..a3ad8805 --- /dev/null +++ b/Tests/IntegrationTests/supabase/.gitignore @@ -0,0 +1,4 @@ +# Supabase +.branches +.temp +.env diff --git a/Tests/IntegrationTests/supabase/config.toml b/Tests/IntegrationTests/supabase/config.toml new file mode 100644 index 00000000..eda36f39 --- /dev/null +++ b/Tests/IntegrationTests/supabase/config.toml @@ -0,0 +1,283 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "IntegrationTests" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 15 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: './seeds/*.sql' +sql_paths = ['./seed.sql'] + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = true +# Allow/disallow testing manual linking of accounts +enable_manual_linking = true +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +[auth.external.github] +enabled = true +client_id = "client id" +secret = "secret" + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth redirectUrl. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +[edge_runtime] +enabled = true +# Configure one of the supported request policies: `oneshot`, `per_worker`. +# Use `oneshot` for hot reload, or `per_worker` for load testing. +policy = "oneshot" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 + +# Use these configurations to customize your Edge Function. +# [functions.MY_FUNCTION_NAME] +# enabled = true +# verify_jwt = true +# import_map = "./functions/MY_FUNCTION_NAME/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +# entrypoint = "./functions/MY_FUNCTION_NAME/index.ts" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/Tests/IntegrationTests/supabase/functions/stream/.npmrc b/Tests/IntegrationTests/supabase/functions/stream/.npmrc new file mode 100644 index 00000000..48c63886 --- /dev/null +++ b/Tests/IntegrationTests/supabase/functions/stream/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/Tests/IntegrationTests/supabase/functions/stream/deno.json b/Tests/IntegrationTests/supabase/functions/stream/deno.json new file mode 100644 index 00000000..f6ca8454 --- /dev/null +++ b/Tests/IntegrationTests/supabase/functions/stream/deno.json @@ -0,0 +1,3 @@ +{ + "imports": {} +} diff --git a/Tests/IntegrationTests/supabase/functions/stream/index.ts b/Tests/IntegrationTests/supabase/functions/stream/index.ts new file mode 100644 index 00000000..10fb1647 --- /dev/null +++ b/Tests/IntegrationTests/supabase/functions/stream/index.ts @@ -0,0 +1,43 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "jsr:@supabase/functions-js/edge-runtime.d.ts" + +const msg = new TextEncoder().encode('data: hello\r\n\r\n') + +Deno.serve((_) => { + let timerId: number | undefined + + const body = new ReadableStream({ + start(controller) { + timerId = setInterval(() => { + controller.enqueue(msg) + }, 1000) + }, + cancel() { + if (typeof timerId === 'number') { + clearInterval(timerId) + } + }, + }) + + return new Response(body, { + headers: { + 'Content-Type': 'text/event-stream', + }, + }) +}) + +/* To invoke locally: + + 1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start) + 2. Make an HTTP request: + + curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/stream' \ + --header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0' \ + --header 'Content-Type: application/json' \ + --data '{"name":"Functions"}' + +*/ diff --git a/Tests/IntegrationTests/supabase/migrations/20240327182636_init.sql b/Tests/IntegrationTests/supabase/migrations/20240327182636_init.sql new file mode 100644 index 00000000..8245ae27 --- /dev/null +++ b/Tests/IntegrationTests/supabase/migrations/20240327182636_init.sql @@ -0,0 +1,248 @@ +CREATE TABLE key_value_storage( + "key" text PRIMARY KEY, + "value" jsonb NOT NULL +); + +ALTER publication supabase_realtime + ADD TABLE key_value_storage; + +-- Create a second schema +CREATE SCHEMA personal; + +-- USERS +CREATE TYPE public.user_status AS ENUM( + 'ONLINE', + 'OFFLINE' +); + +CREATE TABLE public.users( + username text PRIMARY KEY, + data jsonb DEFAULT NULL, + age_range int4range DEFAULT NULL, + status user_status DEFAULT 'ONLINE' ::public.user_status, + catchphrase tsvector DEFAULT NULL +); + +ALTER TABLE public.users REPLICA IDENTITY + FULL; + +-- Send "previous data" to supabase +COMMENT ON COLUMN public.users.data IS 'For unstructured data and prototyping.'; + +-- CREATE A ZERO-TO-ONE RELATIONSHIP (User can have profile, but not all of them do) +CREATE TABLE public.user_profiles( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + username text REFERENCES users +); + +-- CREATE A TABLE WITH TWO RELATIONS TO SAME DESTINATION WHICH WILL NEED HINTING +CREATE TABLE public.best_friends( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + -- Thoses relations should always be satisfied, never be null + first_user text REFERENCES users NOT NULL, + second_user text REFERENCES users NOT NULL, + -- This relation is nullable, it might be null + third_wheel text REFERENCES users +); + +-- CHANNELS +CREATE TABLE public.channels( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + data jsonb DEFAULT NULL, + slug text +); + +ALTER TABLE public.users REPLICA IDENTITY + FULL; + +-- Send "previous data" to supabase +COMMENT ON COLUMN public.channels.data IS 'For unstructured data and prototyping.'; + +CREATE TABLE public.channel_details( + id bigint PRIMARY KEY REFERENCES channels(id), + details text DEFAULT NULL +); + +-- MESSAGES +CREATE TABLE public.messages( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + data jsonb DEFAULT NULL, + message text, + username text REFERENCES users NOT NULL, + channel_id bigint REFERENCES channels NOT NULL +); + +ALTER TABLE public.messages REPLICA IDENTITY + FULL; + +-- Send "previous data" to supabase +COMMENT ON COLUMN public.messages.data IS 'For unstructured data and prototyping.'; + +-- SELF REFERENCING TABLE +CREATE TABLE public.collections( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + description text, + parent_id bigint +); + +ALTER TABLE public.messages REPLICA IDENTITY + FULL; + +-- Send "previous data" to supabase +-- SELF REFERENCE via parent_id +ALTER TABLE public.collections + ADD CONSTRAINT collections_parent_id_fkey FOREIGN KEY (parent_id) REFERENCES public.collections(id); + +COMMENT ON COLUMN public.messages.data IS 'For unstructured data and prototyping.'; + +-- MANY-TO-MANY RELATIONSHIP USING A JOIN TABLE +-- Create a table for products +CREATE TABLE public.products( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL, + description text, + price decimal(10, 2) NOT NULL +); + +-- Create a table for categories +CREATE TABLE public.categories( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL, + description text +); + +-- Create a join table for the many-to-many relationship between products and categories +CREATE TABLE public.product_categories( + product_id bigint REFERENCES public.products(id) ON DELETE CASCADE, + category_id bigint REFERENCES public.categories(id) ON DELETE CASCADE, + PRIMARY KEY (product_id, category_id) +); + +-- STORED FUNCTION +CREATE FUNCTION public.get_status(name_param text) + RETURNS user_status + AS $$ + SELECT + status + FROM + users + WHERE + username = name_param; +$$ +LANGUAGE SQL +IMMUTABLE; + +CREATE FUNCTION public.get_username_and_status(name_param text) + RETURNS TABLE( + username text, + status user_status + ) + AS $$ + SELECT + username, + status + FROM + users + WHERE + username = name_param; +$$ +LANGUAGE SQL +IMMUTABLE; + +CREATE FUNCTION public.offline_user(name_param text) + RETURNS user_status + AS $$ + UPDATE + users + SET + status = 'OFFLINE' + WHERE + username = name_param + RETURNING + status; +$$ +LANGUAGE SQL +VOLATILE; + +CREATE FUNCTION public.void_func() + RETURNS void + AS $$ +$$ +LANGUAGE SQL; + +CREATE EXTENSION postgis SCHEMA extensions; + +CREATE TABLE public.shops( + id int PRIMARY KEY, + address text, + shop_geom extensions.geometry(point, 4326) +); + +CREATE VIEW public.non_updatable_view AS +SELECT + username +FROM + public.users +LIMIT 1; + +CREATE VIEW public.updatable_view AS +SELECT + username, + 1 AS non_updatable_column +FROM + public.users; + +-- SECOND SCHEMA USERS +CREATE TYPE personal.user_status AS ENUM( + 'ONLINE', + 'OFFLINE' +); + +CREATE TABLE personal.users( + username text PRIMARY KEY, + data jsonb DEFAULT NULL, + age_range int4range DEFAULT NULL, + status user_status DEFAULT 'ONLINE' ::public.user_status +); + +-- SECOND SCHEMA STORED FUNCTION +CREATE FUNCTION personal.get_status(name_param text) + RETURNS user_status + AS $$ + SELECT + status + FROM + users + WHERE + username = name_param; +$$ +LANGUAGE SQL +IMMUTABLE; + +CREATE FUNCTION public.function_with_optional_param(param text DEFAULT '') + RETURNS text + AS $$ + SELECT + param; +$$ +LANGUAGE SQL +IMMUTABLE; + +CREATE FUNCTION public.function_with_array_param(param uuid[]) + RETURNS void + AS '' + LANGUAGE sql + IMMUTABLE; + CREATE TABLE public.cornercase( + id int PRIMARY KEY, + "column whitespace" text, + array_column text[] +); + + CREATE FUNCTION public.get_array_element(arr int[], INDEX int) + RETURNS int AS $$ + SELECT + arr[INDEX]; + $$ + LANGUAGE sql + IMMUTABLE; diff --git a/Tests/IntegrationTests/supabase/seed.sql b/Tests/IntegrationTests/supabase/seed.sql new file mode 100644 index 00000000..1ad59ca8 --- /dev/null +++ b/Tests/IntegrationTests/supabase/seed.sql @@ -0,0 +1,89 @@ +INSERT INTO + public.users (username, status, age_range, catchphrase, data) +VALUES + ('supabot', 'ONLINE', '[1,2)'::int4range, 'fat cat'::tsvector, NULL), + ('kiwicopple', 'OFFLINE', '[25,35)'::int4range, 'cat bat'::tsvector, NUlL), + ('awailas', 'ONLINE', '[25,35)'::int4range, 'bat rat'::tsvector, NULL), + ('dragarcia', 'ONLINE', '[20,30)'::int4range, 'rat fat'::tsvector, NULL), + ('jsonuser', 'ONLINE', '[20,30)'::int4range, 'json test'::tsvector, '{"foo": {"bar": {"nested": "value"}, "baz": "string value"}}'::jsonb); + +INSERT INTO + public.channels (slug) +VALUES + ('public'), + ('random'), + ('other'); + +INSERT INTO + public.messages (message, channel_id, username) +VALUES + ('Hello World 👋', 1, 'supabot'), + ('Perfection is attained, not when there is nothing more to add, but when there is nothing left to take away.', 2, 'supabot'), + ('Some message on channel wihtout details', 3, 'supabot'), + ('Some message on channel wihtout details', 3, 'supabot'); + +INSERT INTO + personal.users (username, status, age_range) +VALUES + ('supabot', 'ONLINE', '[1,2)'::int4range), + ('kiwicopple', 'OFFLINE', '[25,35)'::int4range), + ('awailas', 'ONLINE', '[25,35)'::int4range), + ('dragarcia', 'ONLINE', '[20,30)'::int4range), + ('leroyjenkins', 'ONLINE', '[20,40)'::int4range); + +INSERT INTO shops(id, address, shop_geom) +VALUES + (1, '1369 Cambridge St', 'SRID=4326;POINT(-71.10044 42.373695)'); + +INSERT INTO public.channel_details (id, details) +VALUES + (1, 'Details for public channel'), + (2, 'Details for random channel'); + +INSERT INTO user_profiles (id, username) +VALUES + (1, 'supabot'), + (2, NULL); + +INSERT INTO best_friends(id, first_user, second_user, third_wheel) +VALUES + (1, 'supabot', 'kiwicopple', 'awailas'), + (2, 'supabot', 'awailas', NULL); + +INSERT INTO public.collections (id, description, parent_id) +VALUES + (1, 'Root Collection', NULL), + (2, 'Child of Root', 1), + (3, 'Another Child of Root', 1), + (4, 'Grandchild', 2), + (5, 'Sibling of Grandchild', 2), + (6, 'Child of Another Root', 3); + +-- Insert sample products +INSERT INTO public.products (id, name, description, price) +VALUES + (1, 'Laptop', 'High-performance laptop', 999.99), + (2, 'Smartphone', 'Latest model smartphone', 699.99), + (3, 'Headphones', 'Noise-cancelling headphones', 199.99); + +-- Insert sample categories +INSERT INTO public.categories (id, name, description) +VALUES + (1, 'Electronics', 'Electronic devices and gadgets'), + (2, 'Computers', 'Computer and computer accessories'), + (3, 'Audio', 'Audio equipment'); + +-- Insert product-category relationships +INSERT INTO public.product_categories (product_id, category_id) +VALUES + (1, 1), -- Laptop is in Electronics + (1, 2), -- Laptop is also in Computers + (2, 1), -- Smartphone is in Electronics + (3, 1), -- Headphones are in Electronics + (3, 3); -- Headphones are also in Audio + +INSERT INTO public.cornercase (id, array_column) +VALUES + (1, ARRAY['test', 'one']), + (2, ARRAY['another']), + (3, ARRAY['test2']); \ No newline at end of file diff --git a/Tests/PostgRESTTests/BuildURLRequestTests.swift b/Tests/PostgRESTTests/BuildURLRequestTests.swift deleted file mode 100644 index c4ba68d1..00000000 --- a/Tests/PostgRESTTests/BuildURLRequestTests.swift +++ /dev/null @@ -1,276 +0,0 @@ -import ConcurrencyExtras -import Foundation -import Helpers -import SnapshotTesting -import XCTest - -@testable import PostgREST - -#if canImport(FoundationNetworking) - import FoundationNetworking -#endif - -struct User: Encodable { - var email: String - var username: String? -} - -final class BuildURLRequestTests: XCTestCase { - let url = URL(string: "https://example.supabase.co")! - - struct TestCase: Sendable { - let name: String - let record: Bool - let file: StaticString - let line: UInt - let build: @Sendable (PostgrestClient) async throws -> PostgrestBuilder - - init( - name: String, - record: Bool = false, - file: StaticString = #file, - line: UInt = #line, - build: @escaping @Sendable (PostgrestClient) async throws -> PostgrestBuilder - ) { - self.name = name - self.record = record - self.file = file - self.line = line - self.build = build - } - } - - func testBuildRequest() async throws { - let runningTestCase = ActorIsolated(TestCase?.none) - - let encoder = PostgrestClient.Configuration.jsonEncoder - encoder.outputFormatting = .sortedKeys - - let client = PostgrestClient( - url: url, - schema: nil, - headers: ["X-Client-Info": "postgrest-swift/x.y.z"], - logger: nil, - fetch: { request in - guard let runningTestCase = await runningTestCase.value else { - XCTFail("execute called without a runningTestCase set.") - return (Data(), URLResponse.empty()) - } - - await MainActor.run { [runningTestCase] in - assertSnapshot( - of: request, - as: .curl, - named: runningTestCase.name, - record: runningTestCase.record, - file: runningTestCase.file, - testName: "testBuildRequest()", - line: runningTestCase.line - ) - } - - return (Data(), URLResponse.empty()) - }, - encoder: encoder - ) - - let testCases: [TestCase] = [ - TestCase(name: "select all users where email ends with '@supabase.co'") { client in - client.from("users") - .select() - .like("email", pattern: "%@supabase.co") - }, - TestCase(name: "insert new user") { client in - try client.from("users") - .insert(User(email: "johndoe@supabase.io")) - }, - TestCase(name: "bulk insert users") { client in - try client.from("users") - .insert( - [ - User(email: "johndoe@supabase.io"), - User(email: "johndoe2@supabase.io", username: "johndoe2"), - ] - ) - }, - TestCase(name: "call rpc") { client in - try client.rpc("test_fcn", params: ["KEY": "VALUE"]) - }, - TestCase(name: "call rpc without parameter") { client in - try client.rpc("test_fcn") - }, - TestCase(name: "call rpc with filter") { client in - try client.rpc("test_fcn").eq("id", value: 1) - }, - TestCase(name: "test all filters and count") { client in - var query = client.from("todos").select() - - for op in PostgrestFilterBuilder.Operator.allCases { - query = query.filter("column", operator: op.rawValue, value: "Some value") - } - - return query - }, - TestCase(name: "test in filter") { client in - client.from("todos").select().in("id", values: [1, 2, 3]) - }, - TestCase(name: "test contains filter with dictionary") { client in - client.from("users").select("name") - .contains("address", value: ["postcode": 90210]) - }, - TestCase(name: "test contains filter with array") { client in - client.from("users") - .select() - .contains("name", value: ["is:online", "faction:red"]) - }, - TestCase(name: "test or filter with referenced table") { client in - client.from("users") - .select("*, messages(*)") - .or("public.eq.true,recipient_id.eq.1", referencedTable: "messages") - }, - TestCase(name: "test upsert not ignoring duplicates") { client in - try client.from("users") - .upsert(User(email: "johndoe@supabase.io")) - }, - TestCase(name: "bulk upsert") { client in - try client.from("users") - .upsert( - [ - User(email: "johndoe@supabase.io"), - User(email: "johndoe2@supabase.io", username: "johndoe2"), - ] - ) - }, - TestCase(name: "select after bulk upsert") { client in - try client.from("users") - .upsert( - [ - User(email: "johndoe@supabase.io"), - User(email: "johndoe2@supabase.io"), - ], - onConflict: "username" - ) - .select() - }, - TestCase(name: "test upsert ignoring duplicates") { client in - try client.from("users") - .upsert(User(email: "johndoe@supabase.io"), ignoreDuplicates: true) - }, - TestCase(name: "query with + character") { client in - client.from("users") - .select() - .eq("id", value: "Cigányka-ér (0+400 cskm) vízrajzi állomás") - }, - TestCase(name: "query with timestampz") { client in - client.from("tasks") - .select() - .gt("received_at", value: "2023-03-23T15:50:30.511743+00:00") - .order("received_at") - }, - TestCase(name: "query non-default schema") { client in - client.schema("storage") - .from("objects") - .select() - }, - TestCase(name: "select after an insert") { client in - try client.from("users") - .insert(User(email: "johndoe@supabase.io")) - .select("id,email") - }, - TestCase(name: "query if nil value") { client in - client.from("users") - .select() - .is("email", value: nil) - }, - TestCase(name: "likeAllOf") { client in - client.from("users") - .select() - .likeAllOf("email", patterns: ["%@supabase.io", "%@supabase.com"]) - }, - TestCase(name: "likeAnyOf") { client in - client.from("users") - .select() - .likeAnyOf("email", patterns: ["%@supabase.io", "%@supabase.com"]) - }, - TestCase(name: "iLikeAllOf") { client in - client.from("users") - .select() - .iLikeAllOf("email", patterns: ["%@supabase.io", "%@supabase.com"]) - }, - TestCase(name: "iLikeAnyOf") { client in - client.from("users") - .select() - .iLikeAnyOf("email", patterns: ["%@supabase.io", "%@supabase.com"]) - }, - TestCase(name: "containedBy using array") { client in - client.from("users") - .select() - .containedBy("id", value: ["a", "b", "c"]) - }, - TestCase(name: "containedBy using range") { client in - client.from("users") - .select() - .containedBy("age", value: "[10,20]") - }, - TestCase(name: "containedBy using json") { client in - client.from("users") - .select() - .containedBy("userMetadata", value: ["age": 18]) - }, - TestCase(name: "filter starting with non-alphanumeric") { client in - client.from("users") - .select() - .eq("to", value: "+16505555555") - }, - TestCase(name: "filter using Date") { client in - client.from("users") - .select() - .gt("created_at", value: Date(timeIntervalSince1970: 0)) - }, - TestCase(name: "rpc call with head") { client in - try client.rpc("sum", head: true) - }, - TestCase(name: "rpc call with get") { client in - try client.rpc("sum", get: true) - }, - TestCase(name: "rpc call with get and params") { client in - try client.rpc( - "get_array_element", - params: ["array": [37, 420, 64], "index": 2] as AnyJSON, - get: true - ) - }, - ] - - for testCase in testCases { - await runningTestCase.withValue { $0 = testCase } - let builder = try await testCase.build(client) - _ = try? await builder.execute() - } - } - - func testSessionConfiguration() { - let client = PostgrestClient(url: url, schema: nil, logger: nil) - let clientInfoHeader = client.configuration.headers["X-Client-Info"] - XCTAssertNotNil(clientInfoHeader) - } -} - -extension URLResponse { - // Windows and Linux don't have the ability to empty initialize a URLResponse like `URLResponse()` - // so - // We provide a function that can give us the right value on an platform. - // See https://github.com/apple/swift-corelibs-foundation/pull/4778 - fileprivate static func empty() -> URLResponse { - #if os(Windows) || os(Linux) - URLResponse( - url: .init(string: "https://supabase.com")!, - mimeType: nil, - expectedContentLength: 0, - textEncodingName: nil - ) - #else - URLResponse() - #endif - } -} diff --git a/Tests/PostgRESTTests/PostgresQueryTests.swift b/Tests/PostgRESTTests/PostgresQueryTests.swift new file mode 100644 index 00000000..4b30f702 --- /dev/null +++ b/Tests/PostgRESTTests/PostgresQueryTests.swift @@ -0,0 +1,55 @@ +// +// PostgrestQueryTests.swift +// Supabase +// +// Created by Guilherme Souza on 21/01/25. +// + +import InlineSnapshotTesting +import Mocker +import PostgREST +import TestHelpers +import XCTest + +class PostgrestQueryTests: XCTestCase { + let url = URL(string: "http://localhost:54321/rest/v1")! + + let sessionConfiguration: URLSessionConfiguration = { + let configuration = URLSessionConfiguration.default + configuration.protocolClasses = [MockingURLProtocol.self] + return configuration + }() + + lazy var session = URLSession(configuration: sessionConfiguration) + + lazy var sut = PostgrestClient( + url: url, + headers: [ + "apikey": + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" + ], + logger: nil, + fetch: { + try await self.session.data(for: $0) + }, + encoder: { + let encoder = PostgrestClient.Configuration.jsonEncoder + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + ) + + struct User: Codable { + let id: Int + let username: String + } + + struct Country: Decodable { + let name: String + let cities: [City] + + struct City: Decodable { + let name: String + } + } +} diff --git a/Tests/PostgRESTTests/PostgrestBuilderTests.swift b/Tests/PostgRESTTests/PostgrestBuilderTests.swift index 1fa049a4..21913870 100644 --- a/Tests/PostgRESTTests/PostgrestBuilderTests.swift +++ b/Tests/PostgRESTTests/PostgrestBuilderTests.swift @@ -5,19 +5,223 @@ // Created by Guilherme Souza on 20/08/24. // -@testable import PostgREST +import InlineSnapshotTesting +import Mocker import XCTest -final class PostgrestBuilderTests: XCTestCase { - let url = URL(string: "http://localhost:54321/rest/v1")! +@testable import PostgREST +final class PostgrestBuilderTests: PostgrestQueryTests { func testCustomHeaderOnAPerCallBasis() throws { + let url = URL(string: "http://localhost:54321/rest/v1")! let postgrest1 = PostgrestClient(url: url, headers: ["apikey": "foo"], logger: nil) let postgrest2 = try postgrest1.rpc("void_func").setHeader(name: .init("apikey")!, value: "bar") // Original client object isn't affected - XCTAssertEqual(postgrest1.from("users").select().mutableState.request.headers[.init("apikey")!], "foo") + XCTAssertEqual( + postgrest1.from("users").select().mutableState.request.headers[.init("apikey")!], "foo") // Derived client object uses new header value XCTAssertEqual(postgrest2.mutableState.request.headers[.init("apikey")!], "bar") } + + func testExecuteWithNonSuccessStatusCode() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 400, + data: [ + .get: Data( + """ + { + "message": "Bad Request" + } + """.utf8 + ) + ] + ) + .register() + + do { + try await sut + .from("users") + .select() + .execute() + } catch let error as PostgrestError { + XCTAssertEqual(error.message, "Bad Request") + } + } + + func testExecuteWithNonJSONError() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 400, + data: [ + .get: Data("Bad Request".utf8) + ] + ) + .register() + + do { + try await sut + .from("users") + .select() + .execute() + } catch let error as HTTPError { + XCTAssertEqual(error.data, Data("Bad Request".utf8)) + XCTAssertEqual(error.response.statusCode, 400) + } + } + + func testExecuteWithHead() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .head: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --head \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=*" + """# + } + .register() + + try await sut.from("users") + .select() + .execute(options: FetchOptions(head: true)) + } + + func testExecuteWithCount() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data("[]".utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "Prefer: count=exact" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=*" + """# + } + .register() + + try await sut.from("users") + .select() + .execute(options: FetchOptions(count: .exact)) + } + + func testExecuteWithCustomSchema() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data("[]".utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Accept-Profile: private" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=*" + """# + } + .register() + + try await sut + .schema("private") + .from("users") + .select() + .execute() + } + + func testExecuteWithCustomSchemaAndHeadMethod() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .head: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --head \ + --header "Accept: application/json" \ + --header "Accept-Profile: private" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=*" + """# + } + .register() + + try await sut + .schema("private") + .from("users") + .select() + .execute(options: FetchOptions(head: true)) + } + + func testExecuteWithCustomSchemaAndPostMethod() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 201, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Accept: application/json" \ + --header "Content-Length: 19" \ + --header "Content-Profile: private" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"username\":\"test\"}" \ + "http://localhost:54321/rest/v1/users" + """# + } + .register() + + try await sut + .schema("private") + .from("users") + .insert(["username": "test"]) + .execute() + } + + func testSetHeader() { + let query = sut.from("users") + .setHeader(name: "key", value: "value") + + XCTAssertEqual(query.mutableState.request.headers[.init("key")!], "value") + } } diff --git a/Tests/PostgRESTTests/PostgrestFilterBuilderTests.swift b/Tests/PostgRESTTests/PostgrestFilterBuilderTests.swift new file mode 100644 index 00000000..e60d1178 --- /dev/null +++ b/Tests/PostgRESTTests/PostgrestFilterBuilderTests.swift @@ -0,0 +1,672 @@ +// +// PostgrestFilterBuilderTests.swift +// Supabase +// +// Created by Guilherme Souza on 21/01/25. +// + +import InlineSnapshotTesting +import Mocker +import PostgREST +import XCTest + +final class PostgrestFilterBuilderTests: PostgrestQueryTests { + + override func setUp() { + super.setUp() + // isRecording = true + } + + func testNotFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=*&status=not.eq.OFFLINE" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .not("status", operator: .eq, value: "OFFLINE") + .execute() + } + + func testOrFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?or=(status.eq.OFFLINE,username.eq.test)&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .or("status.eq.OFFLINE,username.eq.test") + .execute() + } + + func testOrFilterWithReferencedTable() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?messages.or=(public.eq.true,recipient_id.eq.1)&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .or("public.eq.true,recipient_id.eq.1", referencedTable: "messages") + .execute() + } + + func testContainsFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?address=cs.%7B%22postcode%22:90210%7D&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .contains("address", value: ["postcode": 90210]) + .execute() + } + + func testTextSearchFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?description=fts(english).programmer&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .textSearch("description", query: "programmer", config: "english") + .execute() + } + + func testMultipleFilters() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?age=gte.18&select=*&status=eq.active" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .gte("age", value: 18) + .eq("status", value: "active") + .execute() + } + + func testLikeFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?email=like.%25@example.com&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .like("email", pattern: "%@example.com") + .execute() + } + + func testILikeFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?email=ilike.%25@EXAMPLE.COM&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .ilike("email", pattern: "%@EXAMPLE.COM") + .execute() + } + + func testIsFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?deleted_at=is.NULL&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .is("deleted_at", value: nil) + .execute() + } + + func testInFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=*&status=in.(active,pending)" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .in("status", values: ["active", "pending"]) + .execute() + } + + func testContainedByFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?roles=cd.%7Badmin,user%7D&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .containedBy("roles", value: ["admin", "user"]) + .execute() + } + + func testRangeFilters() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?age_range=sl.%5B18,25)&fifth_range=adj.%5B55,65)&fourth_range=nxr.%5B45,55)&other_range=sr.%5B25,35)&select=*&third_range=nxl.%5B35,45)" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .rangeLt("age_range", range: "[18,25)") + .rangeGt("other_range", range: "[25,35)") + .rangeGte("third_range", range: "[35,45)") + .rangeLte("fourth_range", range: "[45,55)") + .rangeAdjacent("fifth_range", range: "[55,65)") + .execute() + } + + func testOverlapsFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?schedule=ov.%7B9:00,17:00%7D&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .overlaps("schedule", value: ["9:00", "17:00"]) + .execute() + } + + func testMatchFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?role=eq.admin&select=*&status=eq.active" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .match(["status": "active", "role": "admin"]) + .execute() + } + + func testFilterEscapeHatch() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?created_at=gt.2023-01-01&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .filter("created_at", operator: "gt", value: "2023-01-01") + .execute() + } + + func testNeqFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=*&status=neq.inactive" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .neq("status", value: "inactive") + .execute() + } + + func testGtFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?age=gt.21&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .gt("age", value: 21) + .execute() + } + + func testLtFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?age=lt.65&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .lt("age", value: 65) + .execute() + } + + func testLteFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?age=lte.65&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .lte("age", value: 65) + .execute() + } + + func testLikeAllOfFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?name=like(all).%7B%25test%25,%25user%25%7D&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .likeAllOf("name", patterns: ["%test%", "%user%"]) + .execute() + } + + func testLikeAnyOfFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?name=like(any).%7B%25test%25,%25user%25%7D&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .likeAnyOf("name", patterns: ["%test%", "%user%"]) + .execute() + } + + func testILikeAllOfFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?name=ilike(all).%7B%25TEST%25,%25USER%25%7D&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .iLikeAllOf("name", patterns: ["%TEST%", "%USER%"]) + .execute() + } + + func testILikeAnyOfFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?name=ilike(any).%7B%25TEST%25,%25USER%25%7D&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .iLikeAnyOf("name", patterns: ["%TEST%", "%USER%"]) + .execute() + } + + func testFtsFilter() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [.get: Data("[]".utf8)] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?description=fts.programmer&select=*" + """# + } + .register() + + _ = + try await sut + .from("users") + .select() + .fts("description", query: "programmer") + .execute() + } +} diff --git a/Tests/PostgRESTTests/PostgrestQueryBuilderTests.swift b/Tests/PostgRESTTests/PostgrestQueryBuilderTests.swift new file mode 100644 index 00000000..173ceb05 --- /dev/null +++ b/Tests/PostgRESTTests/PostgrestQueryBuilderTests.swift @@ -0,0 +1,363 @@ +// +// PostgrestQueryBuilderTests.swift +// Supabase +// +// Created by Guilherme Souza on 21/01/25. +// + +import InlineSnapshotTesting +import Mocker +import PostgREST +import TestHelpers +import XCTest + +final class PostgrestQueryBuilderTests: PostgrestQueryTests { + override func setUp() { + super.setUp() + // isRecording = true + } + + func testSetAuth() { + XCTAssertNil(sut.configuration.headers["Authorization"]) + sut.setAuth("token") + XCTAssertEqual(sut.configuration.headers["Authorization"], "Bearer token") + + sut.setAuth(nil) + XCTAssertNil(sut.configuration.headers["Authorization"]) + } + + func testSelect() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + [ + { + "id": 1, + "username": "supabase" + } + ] + """.utf8 + ) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=*" + """# + } + .register() + + let users = + try await sut + .from("users") + .select() + .execute() + .value as [User] + + XCTAssertEqual(users[0].id, 1) + XCTAssertEqual(users[0].username, "supabase") + } + + func testSelectWithWhitespaceInQuery() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=somecolumn" + """# + } + .register() + + try await sut + .from("users") + .select("some column") + .execute() + } + + func testSelectWithQuoteInQuery() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=some%22column%22" + """# + } + .register() + + try await sut + .from("users") + .select(#"some "column""#) + .execute() + } + + func testSelectWithCount() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 200, + data: [ + .head: Data() + ], + additionalHeaders: [ + "Content-Range": "0-9/10" + ] + ) + .snapshotRequest { + #""" + curl \ + --head \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "Prefer: count=exact" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?select=*" + """# + } + .register() + + let count = + try await sut + .from("users") + .select(head: true, count: .exact) + .execute() + .count + + XCTAssertEqual(count, 10) + } + + func testInsert() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 201, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Accept: application/json" \ + --header "Content-Length: 59" \ + --header "Content-Type: application/json" \ + --header "Prefer: return=minimal,count=estimated" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "[{\"id\":1,\"username\":\"supabase\"},{\"id\":1,\"username\":\"supa\"}]" \ + "http://localhost:54321/rest/v1/users?columns=id,username" + """# + } + .register() + + try await sut + .from("users") + .insert( + [ + User(id: 1, username: "supabase"), + User(id: 1, username: "supa"), + ], + returning: .minimal, + count: .estimated + ) + .execute() + } + + func testInsertWithExistingPreferHeader() async throws { + Mock( + url: url.appendingPathComponent("users"), + statusCode: 201, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Accept: application/json" \ + --header "Content-Length: 30" \ + --header "Content-Type: application/json" \ + --header "Prefer: existing=value,return=minimal" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"id\":1,\"username\":\"supabase\"}" \ + "http://localhost:54321/rest/v1/users" + """# + } + .register() + + try await sut + .from("users") + .setHeader(name: "Prefer", value: "existing=value") + .insert(User(id: 1, username: "supabase"), returning: .minimal) + .execute() + } + + func testUpdate() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 201, + data: [ + .patch: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request PATCH \ + --header "Accept: application/json" \ + --header "Content-Length: 24" \ + --header "Content-Type: application/json" \ + --header "Prefer: existing=value,return=minimal,count=planned" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"username\":\"supabase2\"}" \ + "http://localhost:54321/rest/v1/users?id=eq.1" + """# + } + .register() + + try await sut + .from("users") + .setHeader(name: "Prefer", value: "existing=value") + .update(["username": "supabase2"], returning: .minimal, count: .planned) + .eq("id", value: 1) + .execute() + } + + func testUpsert() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 201, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Accept: application/json" \ + --header "Content-Length: 60" \ + --header "Content-Type: application/json" \ + --header "Prefer: existing=value,resolution=merge-duplicates,return=minimal,count=estimated" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "[{\"id\":1,\"username\":\"admin\"},{\"id\":2,\"username\":\"supabase\"}]" \ + "http://localhost:54321/rest/v1/users?columns=id,username&on_conflict=username" + """# + } + .register() + + try await sut + .from("users") + .setHeader(name: "Prefer", value: "existing=value") + .upsert( + [ + User(id: 1, username: "admin"), + User(id: 2, username: "supabase"), + ], + onConflict: "username", + returning: .minimal, + count: .estimated + ) + .execute() + } + + func testUpsertIgnoreDuplicates() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 201, + data: [ + .post: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Accept: application/json" \ + --header "Content-Length: 27" \ + --header "Content-Type: application/json" \ + --header "Prefer: resolution=ignore-duplicates,return=representation" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"id\":1,\"username\":\"admin\"}" \ + "http://localhost:54321/rest/v1/users" + """# + } + .register() + + try await sut + .from("users") + .upsert(User(id: 1, username: "admin"), ignoreDuplicates: true) + .execute() + } + + func testDelete() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 204, + data: [ + .delete: Data() + ] + ) + .snapshotRequest { + #""" + curl \ + --request DELETE \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "Prefer: existing=value,return=representation,count=estimated" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/users?username=eq.supabase" + """# + } + .register() + + try await sut + .from("users") + .setHeader(name: "Prefer", value: "existing=value") + .delete(count: .estimated) + .eq("username", value: "supabase") + .execute() + } +} diff --git a/Tests/PostgRESTTests/PostgrestRpcBuilderTests.swift b/Tests/PostgRESTTests/PostgrestRpcBuilderTests.swift new file mode 100644 index 00000000..8b163e42 --- /dev/null +++ b/Tests/PostgRESTTests/PostgrestRpcBuilderTests.swift @@ -0,0 +1,171 @@ +// +// PostgrestRpcBuilderTests.swift +// Supabase +// +// Created by Guilherme Souza on 21/01/25. +// + +import Helpers +import InlineSnapshotTesting +import Mocker +import PostgREST +import XCTest + +final class PostgrestRpcBuilderTests: PostgrestQueryTests { + func testRpc() async throws { + Mock( + url: url.appendingPathComponent("rpc/list_stored_countries"), + ignoreQuery: true, + statusCode: 200, + data: [ + .post: Data( + """ + { + "id": 1, + "name": "France" + } + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Accept: application/vnd.pgrst.object+json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/rpc/list_stored_countries?id=eq.1" + """# + } + .register() + + let country = + try await sut + .rpc("list_stored_countries") + .eq("id", value: 1) + .single() + .execute() + .value as JSONObject + + XCTAssertEqual(country["name"]?.stringValue, "France") + } + + func testRpcReadOnly() async throws { + Mock( + url: url.appendingPathComponent("rpc/hello_world"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data("Hello World".utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/rpc/hello_world" + """# + } + .register() + + try await sut + .rpc("hello_world", get: true) + .execute() + } + + func testRpcWithGetMethodAndNonJSONObjectShouldThrowError() async throws { + do { + try await sut + .rpc("hello", params: [1, 2, 3], get: true) + .execute() + } catch let error as PostgrestError { + XCTAssertEqual( + error.message, "Params should be a key-value type when using `GET` or `HEAD` options.") + } + } + + func testRpcWithHeadMethodAndNonJSONObjectShouldThrowError() async throws { + do { + try await sut + .rpc("hello", params: [1, 2, 3], head: true) + .execute() + } catch let error as PostgrestError { + XCTAssertEqual( + error.message, "Params should be a key-value type when using `GET` or `HEAD` options.") + } + } + + func testRpcWithGetMethodAndJSOBOjectShouldCleanArray() async throws { + Mock( + url: url.appendingPathComponent("rpc/sum"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + { + "sum": 6 + } + """.utf8 + ) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/rpc/sum?key=value&numbers=%7B1,2,3%7D" + """# + } + .register() + + struct Response: Decodable { + let sum: Int + } + + let response = + try await sut + .rpc( + "sum", + params: [ + "numbers": [1, 2, 3], + "key": "value" + ] as JSONObject, + get: true + ) + .execute() + .value as Response + + XCTAssertEqual(response.sum, 6) + } + + func testRpcWithCount() async throws { + Mock( + url: url.appendingPathComponent("rpc/hello"), + statusCode: 200, + data: [.post: Data()] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "Prefer: count=estimated" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/rpc/hello" + """# + } + .register() + + try await sut.rpc("hello", count: .estimated).execute() + } +} diff --git a/Tests/PostgRESTTests/PostgrestTransformBuilderTests.swift b/Tests/PostgRESTTests/PostgrestTransformBuilderTests.swift new file mode 100644 index 00000000..7551a827 --- /dev/null +++ b/Tests/PostgRESTTests/PostgrestTransformBuilderTests.swift @@ -0,0 +1,418 @@ +// +// PostgrestTransformBuilderTests.swift +// Supabase +// +// Created by Guilherme Souza on 21/01/25. +// + +import Mocker +import PostgREST +import XCTest + +final class PostgrestTransformBuilderTests: PostgrestQueryTests { + + func testSelect() async throws { + Mock( + url: url.appendingPathComponent("users"), + ignoreQuery: true, + statusCode: 201, + data: [ + .post: Data(#"{"username":"admin""#.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --request POST \ + --header "Accept: application/json" \ + --header "Content-Length: 27" \ + --header "Content-Type: application/json" \ + --header "Prefer: return=representation" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + --data "{\"id\":1,\"username\":\"admin\"}" \ + "http://localhost:54321/rest/v1/users?select=username,%22first%20name%22" + """# + } + .register() + + try await sut + .from("users") + .insert(User(id: 1, username: "admin"), returning: .minimal) + .select("username, \"first name\"") + .execute() + } + + func testOrder() async throws { + Mock( + url: url.appendingPathComponent("cities"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + [ + { + "name": "United States", + "cities": [ + { + "name": "New York City" + }, + { + "name": "Atlanta" + } + ] + }, + { + "name": "Vanuatu", + "cities": [] + } + ] + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/cities?countries.order=name.asc.nullslast&select=name,country:countries(name)" + """# + } + .register() + + let countries = + try await sut + .from("cities") + .select( + """ + name, + country:countries( + name + ) + """ + ) + .order("name", ascending: true, referencedTable: "countries") + .execute() + .value as [Country] + + XCTAssertEqual(countries[0].name, "United States") + XCTAssertEqual(countries[0].cities[0].name, "New York City") + } + + func testMultipleOrder() async throws { + Mock( + url: url.appendingPathComponent("cities"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data("[]".utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/cities?order=num_of_habitants.asc.nullslast,name.desc.nullsfirst&select=name,num_of_habitants" + """# + } + .register() + + try await sut + .from("cities") + .select("name,num_of_habitants") + .order("num_of_habitants") + .order("name", ascending: false, nullsFirst: true) + .execute() + } + + func testLimit() async throws { + Mock( + url: url.appendingPathComponent("countries"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + [ + { + "name": "United States", + "cities": [ + { + "name": "Atlanta" + } + ] + } + ] + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/countries?cities.limit=1&select=name,cities(name)" + """# + } + .register() + + let countries = + try await sut + .from("countries") + .select( + """ + name, + cities ( + name + ) + """ + ) + .limit(1, referencedTable: "cities") + .execute() + .value as [Country] + + XCTAssertEqual(countries[0].name, "United States") + } + + func testRange() async throws { + Mock( + url: url.appendingPathComponent("countries"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + [ + { + "name": "United States", + "cities": [ + { + "name": "Atlanta" + } + ] + } + ] + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/countries?limit=2&offset=0&select=name,cities(name)" + """# + } + .register() + + let countries = + try await sut + .from("countries") + .select( + """ + name, + cities ( + name + ) + """ + ) + .range(from: 0, to: 1) + .execute() + .value as [Country] + + XCTAssertEqual(countries[0].name, "United States") + } + + func testRangeWithReferencedTable() async throws { + Mock( + url: url.appendingPathComponent("countries"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data("[]".utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/countries?cities.limit=2&cities.offset=0&select=name,cities(name)" + """# + } + .register() + + try await sut + .from("countries") + .select( + """ + name, + cities ( + name + ) + """ + ) + .range(from: 0, to: 1, referencedTable: "cities") + .execute() + } + + func testSingle() async throws { + Mock( + url: url.appendingPathComponent("countries"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + { + "name": "United States" + } + """.utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/vnd.pgrst.object+json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/countries?limit=1&select=name" + """# + } + .register() + + let country = + try await sut + .from("countries") + .select("name") + .limit(1) + .single() + .execute() + .value as [String: String] + + XCTAssertEqual(country["name"], "United States") + } + + func testCSV() async throws { + Mock( + url: url.appendingPathComponent("countries"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data("id,name\n1,Afghanistan\n2,Albania\n3,Algeria".utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: text/csv" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/countries?select=*" + """# + } + .register() + + let csv = + try await sut + .from("countries") + .select() + .csv() + .execute() + .string() + + let ids = + csv? + .split(separator: "\n") + .dropFirst() + .map { $0.split(separator: ",").first! } ?? [] + + XCTAssertEqual(ids, ["1", "2", "3"]) + } + + func testGeoJSON() async throws { + Mock( + url: url.appendingPathComponent("countries"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data("[]".utf8) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/geo+json" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/countries?select=area" + """# + } + .register() + + try await sut + .from("countries") + .select("area") + .geojson() + .execute() + } + + func testExplain() async throws { + Mock( + url: url.appendingPathComponent("countries"), + ignoreQuery: true, + statusCode: 200, + data: [ + .get: Data( + """ + Aggregate (cost=33.34..33.36 rows=1 width=112) (actual time=0.041..0.041 rows=1 loops=1) + Output: NULL::bigint, count(ROW(countries.id, countries.name)), COALESCE(json_agg(ROW(countries.id, countries.name)), '[]'::json), NULLIF(current_setting('response.headers'::text, true), ''::text), NULLIF(current_setting('response.status'::text, true), ''::text) + -> Limit (cost=0.00..18.33 rows=1000 width=40) (actual time=0.005..0.006 rows=3 loops=1) + Output: countries.id, countries.name + -> Seq Scan on public.countries (cost=0.00..22.00 rows=1200 width=40) (actual time=0.004..0.005 rows=3 loops=1) + Output: countries.id, countries.name + Query Identifier: -4730654291623321173 + Planning Time: 0.407 ms + Execution Time: 0.119 ms + """.utf8 + ) + ] + ) + .snapshotRequest { + #""" + curl \ + --header "Accept: application/vnd.pgrst.plan+\"text\"; for=application/json; options=analyze|verbose;" \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: postgrest-swift/0.0.0" \ + --header "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" \ + "http://localhost:54321/rest/v1/countries?select=*" + """# + } + .register() + + let explain = + try await sut + .from("countries") + .select() + .explain(analyze: true, verbose: true) + .execute() + .string() ?? "" + + XCTAssertTrue(explain.contains("Aggregate")) + } +} diff --git a/Tests/PostgRESTTests/URLQueryRepresentableTests.swift b/Tests/PostgRESTTests/URLQueryRepresentableTests.swift index bc7b53b0..6b10cea6 100644 --- a/Tests/PostgRESTTests/URLQueryRepresentableTests.swift +++ b/Tests/PostgRESTTests/URLQueryRepresentableTests.swift @@ -23,4 +23,20 @@ final class URLQueryRepresentableTests: XCTestCase { XCTAssertEqual(AnyJSON.bool(true).queryValue, "true") XCTAssertEqual(AnyJSON.null.queryValue, "NULL") } + + func testOptional() { + XCTAssertEqual(Optional.some([1, 2]).queryValue, "{1,2}") + XCTAssertEqual(Optional<[Int]>.none.queryValue, "NULL") + } + + func testUUID() { + XCTAssertEqual(UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!.queryValue, "E621E1F8-C36C-495A-93FC-0C247A3E6E5F") + } + + func testDate() { + XCTAssertEqual( + Date(timeIntervalSince1970: 1737465985).queryValue, + "2025-01-21T13:26:25.000Z" + ) + } } diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.bulk-insert-users.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.bulk-insert-users.txt deleted file mode 100644 index e9fdf5b6..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.bulk-insert-users.txt +++ /dev/null @@ -1,7 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - --data "[{\"email\":\"johndoe@supabase.io\"},{\"email\":\"johndoe2@supabase.io\",\"username\":\"johndoe2\"}]" \ - "https://example.supabase.co/users?columns=email,username" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.bulk-upsert.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.bulk-upsert.txt deleted file mode 100644 index 43a239d3..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.bulk-upsert.txt +++ /dev/null @@ -1,8 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Prefer: resolution=merge-duplicates,return=representation" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - --data "[{\"email\":\"johndoe@supabase.io\"},{\"email\":\"johndoe2@supabase.io\",\"username\":\"johndoe2\"}]" \ - "https://example.supabase.co/users?columns=email,username" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.call-rpc-with-filter.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.call-rpc-with-filter.txt deleted file mode 100644 index f1f12813..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.call-rpc-with-filter.txt +++ /dev/null @@ -1,6 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/rpc/test_fcn?id=eq.1" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.call-rpc-without-parameter.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.call-rpc-without-parameter.txt deleted file mode 100644 index 3940bc11..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.call-rpc-without-parameter.txt +++ /dev/null @@ -1,6 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/rpc/test_fcn" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.call-rpc.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.call-rpc.txt deleted file mode 100644 index 44fc6091..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.call-rpc.txt +++ /dev/null @@ -1,7 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - --data "{\"KEY\":\"VALUE\"}" \ - "https://example.supabase.co/rpc/test_fcn" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-array.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-array.txt deleted file mode 100644 index 536bbe05..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-array.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?id=cd.%7Ba,b,c%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-json.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-json.txt deleted file mode 100644 index e057183b..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-json.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?select=*&userMetadata=cd.%7B%22age%22:18%7D" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-range.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-range.txt deleted file mode 100644 index 61d3c710..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.containedBy-using-range.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?age=cd.%5B10,20%5D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.filter-starting-with-non-alphanumeric.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.filter-starting-with-non-alphanumeric.txt deleted file mode 100644 index ecff5260..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.filter-starting-with-non-alphanumeric.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?select=*&to=eq.+16505555555" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.filter-using-Date.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.filter-using-Date.txt deleted file mode 100644 index 82be511a..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.filter-using-Date.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?created_at=gt.1970-01-01T00:00:00.000Z&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAllOf.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAllOf.txt deleted file mode 100644 index 9342ef74..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAllOf.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?email=ilike(all).%7B%25@supabase.io,%25@supabase.com%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAnyOf.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAnyOf.txt deleted file mode 100644 index 768e28cf..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.iLikeAnyOf.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?email=ilike(any).%7B%25@supabase.io,%25@supabase.com%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.insert-new-user.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.insert-new-user.txt deleted file mode 100644 index 6b510ca1..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.insert-new-user.txt +++ /dev/null @@ -1,7 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - --data "{\"email\":\"johndoe@supabase.io\"}" \ - "https://example.supabase.co/users" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAllOf.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAllOf.txt deleted file mode 100644 index 6fe5a420..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAllOf.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?email=like(all).%7B%25@supabase.io,%25@supabase.com%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAnyOf.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAnyOf.txt deleted file mode 100644 index 4e316daf..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.likeAnyOf.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?email=like(any).%7B%25@supabase.io,%25@supabase.com%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-if-nil-value.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-if-nil-value.txt deleted file mode 100644 index cc3a34f0..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-if-nil-value.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?email=is.NULL&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-non-default-schema.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-non-default-schema.txt deleted file mode 100644 index 77935032..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-non-default-schema.txt +++ /dev/null @@ -1,6 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Accept-Profile: storage" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/objects?select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-with-character.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-with-character.txt deleted file mode 100644 index 7b221e8d..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-with-character.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?id=eq.Cig%C3%A1nyka-%C3%A9r%20(0+400%20cskm)%20v%C3%ADzrajzi%20%C3%A1llom%C3%A1s&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-with-timestampz.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-with-timestampz.txt deleted file mode 100644 index 691c3982..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.query-with-timestampz.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/tasks?order=received_at.asc.nullslast&received_at=gt.2023-03-23T15:50:30.511743+00:00&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.rpc-call-with-get-and-params.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.rpc-call-with-get-and-params.txt deleted file mode 100644 index fa22d49c..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.rpc-call-with-get-and-params.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/rpc/get_array_element?array=%7B37,420,64%7D&index=2" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.rpc-call-with-get.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.rpc-call-with-get.txt deleted file mode 100644 index 9fbc377a..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.rpc-call-with-get.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/rpc/sum" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.rpc-call-with-head.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.rpc-call-with-head.txt deleted file mode 100644 index 22092971..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.rpc-call-with-head.txt +++ /dev/null @@ -1,6 +0,0 @@ -curl \ - --head \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/rpc/sum" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.select-after-an-insert.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.select-after-an-insert.txt deleted file mode 100644 index 91236a35..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.select-after-an-insert.txt +++ /dev/null @@ -1,8 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Prefer: return=representation" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - --data "{\"email\":\"johndoe@supabase.io\"}" \ - "https://example.supabase.co/users?select=id,email" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.select-after-bulk-upsert.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.select-after-bulk-upsert.txt deleted file mode 100644 index 53161f23..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.select-after-bulk-upsert.txt +++ /dev/null @@ -1,8 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Prefer: resolution=merge-duplicates,return=representation" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - --data "[{\"email\":\"johndoe@supabase.io\"},{\"email\":\"johndoe2@supabase.io\"}]" \ - "https://example.supabase.co/users?columns=email&on_conflict=username&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.select-all-users-where-email-ends-with-supabase-co.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.select-all-users-where-email-ends-with-supabase-co.txt deleted file mode 100644 index 67997c01..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.select-all-users-where-email-ends-with-supabase-co.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?email=like.%25@supabase.co&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-all-filters-and-count.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-all-filters-and-count.txt deleted file mode 100644 index ab0647ba..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-all-filters-and-count.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/todos?column=eq.Some%20value&column=neq.Some%20value&column=gt.Some%20value&column=gte.Some%20value&column=lt.Some%20value&column=lte.Some%20value&column=like.Some%20value&column=ilike.Some%20value&column=is.Some%20value&column=in.Some%20value&column=cs.Some%20value&column=cd.Some%20value&column=sl.Some%20value&column=sr.Some%20value&column=nxl.Some%20value&column=nxr.Some%20value&column=adj.Some%20value&column=ov.Some%20value&column=fts.Some%20value&column=plfts.Some%20value&column=phfts.Some%20value&column=wfts.Some%20value&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-contains-filter-with-array.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-contains-filter-with-array.txt deleted file mode 100644 index 303a90e7..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-contains-filter-with-array.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?name=cs.%7Bis:online,faction:red%7D&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-contains-filter-with-dictionary.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-contains-filter-with-dictionary.txt deleted file mode 100644 index 70a1b5dc..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-contains-filter-with-dictionary.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?address=cs.%7B%22postcode%22:90210%7D&select=name" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-in-filter.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-in-filter.txt deleted file mode 100644 index cfeea543..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-in-filter.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/todos?id=in.(1,2,3)&select=*" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-or-filter-with-referenced-table.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-or-filter-with-referenced-table.txt deleted file mode 100644 index 7f79742c..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-or-filter-with-referenced-table.txt +++ /dev/null @@ -1,5 +0,0 @@ -curl \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - "https://example.supabase.co/users?messages.or=(public.eq.true,recipient_id.eq.1)&select=*,messages(*)" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-upsert-ignoring-duplicates.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-upsert-ignoring-duplicates.txt deleted file mode 100644 index c9b20f93..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-upsert-ignoring-duplicates.txt +++ /dev/null @@ -1,8 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Prefer: resolution=ignore-duplicates,return=representation" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - --data "{\"email\":\"johndoe@supabase.io\"}" \ - "https://example.supabase.co/users" \ No newline at end of file diff --git a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-upsert-not-ignoring-duplicates.txt b/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-upsert-not-ignoring-duplicates.txt deleted file mode 100644 index 74e39ff5..00000000 --- a/Tests/PostgRESTTests/__Snapshots__/BuildURLRequestTests/testBuildRequest.test-upsert-not-ignoring-duplicates.txt +++ /dev/null @@ -1,8 +0,0 @@ -curl \ - --request POST \ - --header "Accept: application/json" \ - --header "Content-Type: application/json" \ - --header "Prefer: resolution=merge-duplicates,return=representation" \ - --header "X-Client-Info: postgrest-swift/x.y.z" \ - --data "{\"email\":\"johndoe@supabase.io\"}" \ - "https://example.supabase.co/users" \ No newline at end of file diff --git a/Tests/StorageTests/BucketOptionsTests.swift b/Tests/StorageTests/BucketOptionsTests.swift new file mode 100644 index 00000000..a17d79ca --- /dev/null +++ b/Tests/StorageTests/BucketOptionsTests.swift @@ -0,0 +1,25 @@ +import XCTest + +@testable import Storage + +final class BucketOptionsTests: XCTestCase { + func testDefaultInitialization() { + let options = BucketOptions() + + XCTAssertFalse(options.public) + XCTAssertNil(options.fileSizeLimit) + XCTAssertNil(options.allowedMimeTypes) + } + + func testCustomInitialization() { + let options = BucketOptions( + public: true, + fileSizeLimit: "5242880", + allowedMimeTypes: ["image/jpeg", "image/png"] + ) + + XCTAssertTrue(options.public) + XCTAssertEqual(options.fileSizeLimit, "5242880") + XCTAssertEqual(options.allowedMimeTypes, ["image/jpeg", "image/png"]) + } +} diff --git a/Tests/StorageTests/FileOptionsTests.swift b/Tests/StorageTests/FileOptionsTests.swift new file mode 100644 index 00000000..6821ad81 --- /dev/null +++ b/Tests/StorageTests/FileOptionsTests.swift @@ -0,0 +1,30 @@ +import Helpers +import XCTest + +@testable import Storage + +final class FileOptionsTests: XCTestCase { + func testDefaultInitialization() { + let options = FileOptions() + + XCTAssertEqual(options.cacheControl, "3600") + XCTAssertNil(options.contentType) + XCTAssertFalse(options.upsert) + XCTAssertNil(options.metadata) + } + + func testCustomInitialization() { + let metadata: [String: AnyJSON] = ["key": .string("value")] + let options = FileOptions( + cacheControl: "7200", + contentType: "image/jpeg", + upsert: true, + metadata: metadata + ) + + XCTAssertEqual(options.cacheControl, "7200") + XCTAssertEqual(options.contentType, "image/jpeg") + XCTAssertTrue(options.upsert) + XCTAssertEqual(options.metadata?["key"], .string("value")) + } +} diff --git a/Tests/StorageTests/MultipartFormDataTests.swift b/Tests/StorageTests/MultipartFormDataTests.swift new file mode 100644 index 00000000..94d54466 --- /dev/null +++ b/Tests/StorageTests/MultipartFormDataTests.swift @@ -0,0 +1,34 @@ +import XCTest + +@testable import Storage + +final class MultipartFormDataTests: XCTestCase { + func testBoundaryGeneration() { + let formData = MultipartFormData() + XCTAssertFalse(formData.boundary.isEmpty) + XCTAssertTrue(formData.contentType.contains("multipart/form-data; boundary=")) + } + + func testAppendingData() { + let formData = MultipartFormData() + let testData = "Hello World".data(using: .utf8)! + + formData.append(testData, withName: "test", fileName: "test.txt", mimeType: "text/plain") + + XCTAssertGreaterThan(formData.contentLength, 0) + } + + func testContentHeaders() { + let formData = MultipartFormData() + let testData = "Test".data(using: .utf8)! + + formData.append( + testData, + withName: "file", + fileName: "test.txt", + mimeType: "text/plain" + ) + + XCTAssertTrue(formData.contentType.hasPrefix("multipart/form-data")) + } +} diff --git a/Tests/StorageTests/StorageBucketAPITests.swift b/Tests/StorageTests/StorageBucketAPITests.swift new file mode 100644 index 00000000..29bce548 --- /dev/null +++ b/Tests/StorageTests/StorageBucketAPITests.swift @@ -0,0 +1,268 @@ +import InlineSnapshotTesting +import XCTest + +@testable import Storage + +final class StorageBucketAPITests: XCTestCase { + var storage: SupabaseStorageClient! + var mockResponses: [(Data, URLResponse)]! + + var snapshot: ((URLRequest) -> Void)? + + override func setUp() { + super.setUp() + mockResponses = [] + + let mockSession = StorageHTTPSession( + fetch: { [weak self] request in + self?.snapshot?(request) + + guard let response = self?.mockResponses.removeFirst() else { + throw StorageError(message: "No mock response available") + } + return response + }, + upload: { [weak self] request, data in + self?.snapshot?(request) + + guard let response = self?.mockResponses.removeFirst() else { + throw StorageError(message: "No mock response available") + } + return response + } + ) + + JSONEncoder.defaultStorageEncoder.outputFormatting = [ + .sortedKeys + ] + + storage = SupabaseStorageClient( + configuration: StorageClientConfiguration( + url: URL(string: "http://example.com")!, + headers: ["X-Client-Info": "storage-swift/0.0.0"], + session: mockSession, + logger: nil + ) + ) + } + + func testGetBucket() async throws { + let jsonResponse = """ + { + "id": "bucket123", + "name": "test-bucket", + "owner": "owner123", + "public": false, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z" + } + """.data(using: .utf8)! + + mockResponses = [ + ( + jsonResponse, + HTTPURLResponse( + url: URL(string: "http://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --header "X-Client-Info: storage-swift/0.0.0" \ + "http://example.com/bucket/bucket123" + """# + } + } + + let bucket = try await storage.getBucket("bucket123") + XCTAssertEqual(bucket.id, "bucket123") + XCTAssertEqual(bucket.name, "test-bucket") + } + + func testListBuckets() async throws { + let jsonResponse = """ + [{ + "id": "bucket123", + "name": "test-bucket", + "owner": "owner123", + "public": false, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z" + }] + """.data(using: .utf8)! + + mockResponses = [ + ( + jsonResponse, + HTTPURLResponse( + url: URL(string: "http://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --header "X-Client-Info: storage-swift/0.0.0" \ + "http://example.com/bucket" + """# + } + } + + let buckets = try await storage.listBuckets() + XCTAssertEqual(buckets.count, 1) + XCTAssertEqual(buckets[0].name, "test-bucket") + } + + func testCreateBucket() async throws { + let jsonResponse = """ + { + "id": "newbucket", + "name": "new-bucket", + "owner": "owner123", + "public": true, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z" + } + """.data(using: .utf8)! + + mockResponses = [ + ( + jsonResponse, + HTTPURLResponse( + url: URL(string: "http://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --request POST \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: storage-swift/0.0.0" \ + --data "{\"id\":\"newbucket\",\"name\":\"newbucket\",\"public\":true}" \ + "http://example.com/bucket" + """# + } + } + + let options = BucketOptions(public: true) + try await storage.createBucket( + "newbucket", + options: options + ) + } + + func testUpdateBucket() async throws { + let jsonResponse = """ + { + "id": "bucket123", + "name": "updated-bucket", + "owner": "owner123", + "public": true, + "created_at": "2024-01-01T00:00:00.000Z", + "updated_at": "2024-01-01T00:00:00.000Z" + } + """.data(using: .utf8)! + + mockResponses = [ + ( + jsonResponse, + HTTPURLResponse( + url: URL(string: "http://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --request PUT \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: storage-swift/0.0.0" \ + --data "{\"id\":\"bucket123\",\"name\":\"bucket123\",\"public\":true}" \ + "http://example.com/bucket/bucket123" + """# + } + } + + let options = BucketOptions(public: true) + try await storage.updateBucket( + "bucket123", + options: options + ) + } + + func testDeleteBucket() async throws { + mockResponses = [ + ( + Data(), + HTTPURLResponse( + url: URL(string: "http://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --request DELETE \ + --header "X-Client-Info: storage-swift/0.0.0" \ + "http://example.com/bucket/bucket123" + """# + } + } + + try await storage.deleteBucket("bucket123") + } + + func testEmptyBucket() async throws { + mockResponses = [ + ( + Data(), + HTTPURLResponse( + url: URL(string: "http://example.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --request POST \ + --header "X-Client-Info: storage-swift/0.0.0" \ + "http://example.com/bucket/bucket123/empty" + """# + } + } + + try await storage.emptyBucket("bucket123") + } +} diff --git a/Tests/StorageTests/StorageErrorTests.swift b/Tests/StorageTests/StorageErrorTests.swift new file mode 100644 index 00000000..5dc0d16a --- /dev/null +++ b/Tests/StorageTests/StorageErrorTests.swift @@ -0,0 +1,43 @@ +import XCTest + +@testable import Storage + +final class StorageErrorTests: XCTestCase { + func testErrorInitialization() { + let error = StorageError( + statusCode: "404", + message: "File not found", + error: "NotFound" + ) + + XCTAssertEqual(error.statusCode, "404") + XCTAssertEqual(error.message, "File not found") + XCTAssertEqual(error.error, "NotFound") + } + + func testLocalizedError() { + let error = StorageError( + statusCode: "500", + message: "Internal server error", + error: nil + ) + + XCTAssertEqual(error.errorDescription, "Internal server error") + } + + func testDecoding() throws { + let json = """ + { + "statusCode": "403", + "message": "Unauthorized access", + "error": "Forbidden" + } + """.data(using: .utf8)! + + let error = try JSONDecoder().decode(StorageError.self, from: json) + + XCTAssertEqual(error.statusCode, "403") + XCTAssertEqual(error.message, "Unauthorized access") + XCTAssertEqual(error.error, "Forbidden") + } +} diff --git a/Tests/StorageTests/StorageFileAPITests.swift b/Tests/StorageTests/StorageFileAPITests.swift new file mode 100644 index 00000000..0d92057b --- /dev/null +++ b/Tests/StorageTests/StorageFileAPITests.swift @@ -0,0 +1,284 @@ +import Helpers +import InlineSnapshotTesting +import XCTest + +@testable import Storage + +final class StorageFileAPITests: XCTestCase { + let url = URL(string: "http://localhost:54321/storage/v1")! + + var storage: SupabaseStorageClient! + var mockResponses: [(Data, URLResponse)]! + + var snapshot: ((URLRequest) -> Void)? + + override func setUp() { + super.setUp() + mockResponses = [] + + let mockSession = StorageHTTPSession( + fetch: { [weak self] request in + self?.snapshot?(request) + + guard let response = self?.mockResponses.removeFirst() else { + throw StorageError(message: "No mock response available") + } + return response + }, + upload: { [weak self] request, data in + self?.snapshot?(request) + + guard let response = self?.mockResponses.removeFirst() else { + throw StorageError(message: "No mock response available") + } + return response + } + ) + + JSONEncoder.defaultStorageEncoder.outputFormatting = [.sortedKeys] + JSONEncoder.unconfiguredEncoder.outputFormatting = [.sortedKeys] + + storage = SupabaseStorageClient( + configuration: StorageClientConfiguration( + url: url, + headers: ["X-Client-Info": "storage-swift/0.0.0"], + session: mockSession, + logger: nil + ) + ) + } + + func testListFiles() async throws { + // Setup mock response + let jsonResponse = """ + [{ + "name": "test.txt", + "id": "E621E1F8-C36C-495A-93FC-0C247A3E6E5F", + "updatedAt": "2024-01-01T00:00:00Z", + "createdAt": "2024-01-01T00:00:00Z", + "lastAccessedAt": "2024-01-01T00:00:00Z", + "metadata": {} + }] + """.data(using: .utf8)! + + mockResponses = [ + ( + jsonResponse, + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --request POST \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: storage-swift/0.0.0" \ + --data "{\"limit\":100,\"offset\":0,\"prefix\":\"folder\",\"sortBy\":{\"column\":\"name\",\"order\":\"asc\"}}" \ + "http://localhost:54321/storage/v1/object/list/bucket" + """# + } + } + + let result = try await storage.from("bucket").list(path: "folder") + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].name, "test.txt") + } + + func testMove() async throws { + mockResponses = [ + ( + Data(), + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --request POST \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: storage-swift/0.0.0" \ + --data "{\"bucketId\":\"bucket\",\"destinationBucket\":null,\"destinationKey\":\"new\/path.txt\",\"sourceKey\":\"old\/path.txt\"}" \ + "http://localhost:54321/storage/v1/object/move" + """# + } + } + + try await storage.from("bucket").move( + from: "old/path.txt", + to: "new/path.txt" + ) + } + + func testCopy() async throws { + mockResponses = [ + ( + """ + {"Key": "object/dest/file.txt"} + """.data(using: .utf8)!, + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --request POST \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: storage-swift/0.0.0" \ + --data "{\"bucketId\":\"bucket\",\"destinationBucket\":null,\"destinationKey\":\"dest\/file.txt\",\"sourceKey\":\"source\/file.txt\"}" \ + "http://localhost:54321/storage/v1/object/copy" + """# + } + } + + let key = try await storage.from("bucket").copy( + from: "source/file.txt", + to: "dest/file.txt" + ) + XCTAssertEqual(key, "object/dest/file.txt") + } + + func testCreateSignedURL() async throws { + mockResponses = [ + ( + """ + {"signedURL": "object/upload/sign/bucket/file.txt?token=abc.def.ghi"} + """.data(using: .utf8)!, + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --request POST \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: storage-swift/0.0.0" \ + --data "{\"expiresIn\":3600}" \ + "http://localhost:54321/storage/v1/object/sign/bucket/file.txt" + """# + } + } + + let url = try await storage.from("bucket").createSignedURL( + path: "file.txt", + expiresIn: 3600 + ) + XCTAssertEqual( + url.absoluteString, "\(self.url)/object/upload/sign/bucket/file.txt?token=abc.def.ghi") + } + + func testCreateSignedURLs() async throws { + mockResponses = [ + ( + """ + [ + {"path": "file.txt", "signedURL": "object/upload/sign/bucket/file.txt?token=abc.def.ghi"}, + {"path": "file2.txt", "signedURL": "object/upload/sign/bucket/file2.txt?token=abc.def.ghi"} + ] + """.data(using: .utf8)!, + HTTPURLResponse( + url: url, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + snapshot = { + assertInlineSnapshot(of: $0, as: .curl) { + #""" + curl \ + --request POST \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: storage-swift/0.0.0" \ + --data "{\"expiresIn\":3600,\"paths\":[\"file.txt\",\"file2.txt\"]}" \ + "http://localhost:54321/storage/v1/object/sign/bucket" + """# + } + } + + let paths = ["file.txt", "file2.txt"] + let urls = try await storage.from("bucket").createSignedURLs( + paths: paths, + expiresIn: 3600 + ) + XCTAssertEqual(urls.count, 2) + XCTAssertEqual( + urls[0].absoluteString, "\(self.url)/object/upload/sign/bucket/file.txt?token=abc.def.ghi") + XCTAssertEqual( + urls[1].absoluteString, "\(self.url)/object/upload/sign/bucket/file2.txt?token=abc.def.ghi") + } + + func testNonSuccessStatusCode() async throws { + mockResponses = [ + ( + """ + {"message":"Error"} + """.data(using: .utf8)!, + HTTPURLResponse( + url: url, + statusCode: 412, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + do { + try await storage.from("bucket") + .move(from: "source", to: "destination") + } catch let error as StorageError { + XCTAssertEqual(error.message, "Error") + } + } + + func testNonSuccessStatusCodeWithNonJSONResponse() async throws { + mockResponses = [ + ( + "error".data(using: .utf8)!, + HTTPURLResponse( + url: url, + statusCode: 412, + httpVersion: nil, + headerFields: nil + )! + ) + ] + + do { + try await storage.from("bucket") + .move(from: "source", to: "destination") + } catch let error as HTTPError { + XCTAssertEqual(error.data, Data("error".utf8)) + XCTAssertEqual(error.response.statusCode, 412) + } + } +} diff --git a/Tests/StorageTests/TransformOptionsTests.swift b/Tests/StorageTests/TransformOptionsTests.swift new file mode 100644 index 00000000..e1da73cf --- /dev/null +++ b/Tests/StorageTests/TransformOptionsTests.swift @@ -0,0 +1,74 @@ +import XCTest + +@testable import Storage + +final class TransformOptionsTests: XCTestCase { + func testDefaultInitialization() { + let options = TransformOptions() + + XCTAssertNil(options.width) + XCTAssertNil(options.height) + XCTAssertNil(options.resize) + XCTAssertEqual(options.quality, 80) // Default value + XCTAssertNil(options.format) + } + + func testCustomInitialization() { + let options = TransformOptions( + width: 100, + height: 200, + resize: "cover", + quality: 90, + format: "webp" + ) + + XCTAssertEqual(options.width, 100) + XCTAssertEqual(options.height, 200) + XCTAssertEqual(options.resize, "cover") + XCTAssertEqual(options.quality, 90) + XCTAssertEqual(options.format, "webp") + } + + func testQueryItemsGeneration() { + let options = TransformOptions( + width: 100, + height: 200, + resize: "cover", + quality: 90, + format: "webp" + ) + + let queryItems = options.queryItems + + XCTAssertEqual(queryItems.count, 5) + + XCTAssertEqual(queryItems[0].name, "width") + XCTAssertEqual(queryItems[0].value, "100") + + XCTAssertEqual(queryItems[1].name, "height") + XCTAssertEqual(queryItems[1].value, "200") + + XCTAssertEqual(queryItems[2].name, "resize") + XCTAssertEqual(queryItems[2].value, "cover") + + XCTAssertEqual(queryItems[3].name, "quality") + XCTAssertEqual(queryItems[3].value, "90") + + XCTAssertEqual(queryItems[4].name, "format") + XCTAssertEqual(queryItems[4].value, "webp") + } + + func testPartialQueryItemsGeneration() { + let options = TransformOptions(width: 100, quality: 75) + + let queryItems = options.queryItems + + XCTAssertEqual(queryItems.count, 2) + + XCTAssertEqual(queryItems[0].name, "width") + XCTAssertEqual(queryItems[0].value, "100") + + XCTAssertEqual(queryItems[1].name, "quality") + XCTAssertEqual(queryItems[1].value, "75") + } +} diff --git a/Tests/StorageTests/__Snapshots__/StorageBucketAPITests/StorageBucketAPITests-testCreateBucket.1.txt b/Tests/StorageTests/__Snapshots__/StorageBucketAPITests/StorageBucketAPITests-testCreateBucket.1.txt new file mode 100644 index 00000000..62b20e6f --- /dev/null +++ b/Tests/StorageTests/__Snapshots__/StorageBucketAPITests/StorageBucketAPITests-testCreateBucket.1.txt @@ -0,0 +1,6 @@ +curl \ + --request POST \ + --header "Content-Type: application/json" \ + --header "X-Client-Info: storage-swift/2.24.4" \ + --data "{\"public\":true,\"id\":\"newbucket\",\"name\":\"newbucket\"}" \ + "http://example.com/bucket" \ No newline at end of file diff --git a/scripts/generate-coverage.sh b/scripts/generate-coverage.sh index 53b85795..0013f5f0 100755 --- a/scripts/generate-coverage.sh +++ b/scripts/generate-coverage.sh @@ -43,7 +43,7 @@ for TEST_BUNDLE in $TEST_BUNDLES; do xcrun llvm-cov export \ -format=lcov \ -instr-profile "$PROFDATA_FILE" \ - -ignore-filename-regex "Tests/|.build|DerivedData|.derivedData" \ + -ignore-filename-regex "Tests/|.build|DerivedData|.derivedData|Deprecated/|Deprecated.swift" \ "$BINARY_PATH" > "$TEMP_COVERAGE_DIR/$BINARY_NAME.info" if [ $? -ne 0 ]; then @@ -58,7 +58,10 @@ rm -f "$OUTPUT_FILE" # Ensure the output file doesn't already exist for INFO_FILE in "$TEMP_COVERAGE_DIR"/*.info; do if [ -f "$INFO_FILE" ]; then - lcov --add-tracefile "$INFO_FILE" --output-file "$OUTPUT_FILE" + lcov \ + --ignore-errors inconsistent \ + --add-tracefile "$INFO_FILE" \ + --output-file "$OUTPUT_FILE" if [ $? -ne 0 ]; then echo "Failed to merge $INFO_FILE into $OUTPUT_FILE. Exiting." exit 1