diff --git a/Sources/GoTrue/GoTrueClient.swift b/Sources/GoTrue/GoTrueClient.swift index d69ff2bd..845858ec 100644 --- a/Sources/GoTrue/GoTrueClient.swift +++ b/Sources/GoTrue/GoTrueClient.swift @@ -61,7 +61,9 @@ public actor GoTrueClient { Dependencies.current.value!.sessionManager } - private let codeVerifierStorage: CodeVerifierStorage + private var codeVerifierStorage: CodeVerifierStorage { + Dependencies.current.value!.codeVerifierStorage + } private var eventEmitter: EventEmitter { Dependencies.current.value!.eventEmitter @@ -100,15 +102,12 @@ public actor GoTrueClient { } public init(configuration: Configuration) { - let sessionManager = DefaultSessionManager() - - let codeVerifierStorage = DefaultCodeVerifierStorage() let api = APIClient() self.init( configuration: configuration, - sessionManager: sessionManager, - codeVerifierStorage: codeVerifierStorage, + sessionManager: .live, + codeVerifierStorage: .live, api: api, eventEmitter: .live, sessionStorage: .live @@ -124,7 +123,6 @@ public actor GoTrueClient { eventEmitter: EventEmitter, sessionStorage: SessionStorage ) { - self.codeVerifierStorage = codeVerifierStorage self.mfa = GoTrueMFA() Dependencies.current.setValue( @@ -138,7 +136,8 @@ public actor GoTrueClient { refreshSession: { [weak self] in try await self?.refreshSession(refreshToken: $0) ?? .empty } - ) + ), + codeVerifierStorage: codeVerifierStorage ) ) } diff --git a/Sources/GoTrue/Internal/CodeVerifierStorage.swift b/Sources/GoTrue/Internal/CodeVerifierStorage.swift index a55b8d8e..2958e1ed 100644 --- a/Sources/GoTrue/Internal/CodeVerifierStorage.swift +++ b/Sources/GoTrue/Internal/CodeVerifierStorage.swift @@ -1,30 +1,32 @@ import Foundation @_spi(Internal) import _Helpers -protocol CodeVerifierStorage { - func getCodeVerifier() throws -> String? - func storeCodeVerifier(_ code: String) throws - func deleteCodeVerifier() throws +struct CodeVerifierStorage: Sendable { + var getCodeVerifier: @Sendable () throws -> String? + var storeCodeVerifier: @Sendable (_ code: String) throws -> Void + var deleteCodeVerifier: @Sendable () throws -> Void } -struct DefaultCodeVerifierStorage: CodeVerifierStorage { - private var localStorage: GoTrueLocalStorage { - Dependencies.current.value!.configuration.localStorage - } - - private let key = "supabase.code-verifier" - - func getCodeVerifier() throws -> String? { - try localStorage.retrieve(key: key).flatMap { - String(data: $0, encoding: .utf8) +extension CodeVerifierStorage { + static var live: Self = { + var localStorage: GoTrueLocalStorage { + Dependencies.current.value!.configuration.localStorage } - } - func storeCodeVerifier(_ code: String) throws { - try localStorage.store(key: key, value: Data(code.utf8)) - } + let key = "supabase.code-verifier" - func deleteCodeVerifier() throws { - try localStorage.remove(key: key) - } + return Self( + getCodeVerifier: { + try localStorage.retrieve(key: key).flatMap { + String(data: $0, encoding: .utf8) + } + }, + storeCodeVerifier: { code in + try localStorage.store(key: key, value: Data(code.utf8)) + }, + deleteCodeVerifier: { + try localStorage.remove(key: key) + } + ) + }() } diff --git a/Sources/GoTrue/Internal/Dependencies.swift b/Sources/GoTrue/Internal/Dependencies.swift index 720a3c7f..2c3907ca 100644 --- a/Sources/GoTrue/Internal/Dependencies.swift +++ b/Sources/GoTrue/Internal/Dependencies.swift @@ -10,4 +10,5 @@ struct Dependencies: Sendable { var eventEmitter: EventEmitter var sessionStorage: SessionStorage var sessionRefresher: SessionRefresher + var codeVerifierStorage: CodeVerifierStorage } diff --git a/Sources/GoTrue/Internal/SessionManager.swift b/Sources/GoTrue/Internal/SessionManager.swift index 5adc6e9d..a8cf9bf3 100644 --- a/Sources/GoTrue/Internal/SessionManager.swift +++ b/Sources/GoTrue/Internal/SessionManager.swift @@ -6,13 +6,24 @@ struct SessionRefresher: Sendable { var refreshSession: @Sendable (_ refreshToken: String) async throws -> Session } -protocol SessionManager: Sendable { - func session() async throws -> Session - func update(_ session: Session) async throws - func remove() async +struct SessionManager: Sendable { + var session: @Sendable () async throws -> Session + var update: @Sendable (_ session: Session) async throws -> Void + var remove: @Sendable () async -> Void } -actor DefaultSessionManager: SessionManager { +extension SessionManager { + static var live: Self = { + let manager = _LiveSessionManager() + return Self( + session: { try await manager.session() }, + update: { try await manager.update($0) }, + remove: { await manager.remove() } + ) + }() +} + +actor _LiveSessionManager { private var task: Task? private var storage: SessionStorage { diff --git a/Tests/GoTrueTests/GoTrueClientTests.swift b/Tests/GoTrueTests/GoTrueClientTests.swift index d040bb0b..90bed3ff 100644 --- a/Tests/GoTrueTests/GoTrueClientTests.swift +++ b/Tests/GoTrueTests/GoTrueClientTests.swift @@ -12,21 +12,18 @@ import XCTest final class GoTrueClientTests: XCTestCase { - fileprivate var sessionManager: SessionManagerMock! - fileprivate var codeVerifierStorage: CodeVerifierStorageMock! fileprivate var api: APIClient! func testOnAuthStateChange() async throws { let session = Session.validSession - let sut = makeSUT() - sessionManager.sessionResult = .success(session) let events = ActorIsolated([AuthChangeEvent]()) let expectation = self.expectation(description: "onAuthStateChangeEnd") await withDependencies { $0.eventEmitter = .live + $0.sessionManager.session = { session } } operation: { let authStateStream = await sut.onAuthStateChange() @@ -49,9 +46,6 @@ final class GoTrueClientTests: XCTestCase { } private func makeSUT(fetch: GoTrueClient.FetchHandler? = nil) -> GoTrueClient { - sessionManager = SessionManagerMock() - codeVerifierStorage = CodeVerifierStorageMock() - let configuration = GoTrueClient.Configuration( url: clientURL, headers: ["apikey": "dummy.api.key"], @@ -68,8 +62,8 @@ final class GoTrueClientTests: XCTestCase { let sut = GoTrueClient( configuration: configuration, - sessionManager: sessionManager, - codeVerifierStorage: codeVerifierStorage, + sessionManager: .mock, + codeVerifierStorage: .mock, api: api, eventEmitter: .mock, sessionStorage: .mock @@ -82,38 +76,3 @@ final class GoTrueClientTests: XCTestCase { return sut } } - -private final class SessionManagerMock: SessionManager, @unchecked Sendable { - private let lock = NSRecursiveLock() - - var sessionRefresher: SessionRefresher? - func setSessionRefresher(_ refresher: GoTrue.SessionRefresher?) async { - lock.withLock { - sessionRefresher = refresher - } - } - - var sessionResult: Result! - func session() async throws -> GoTrue.Session { - try sessionResult.get() - } - - func update(_ session: GoTrue.Session) async throws {} - - func remove() async {} -} - -final class CodeVerifierStorageMock: CodeVerifierStorage { - var codeVerifier: String? - func getCodeVerifier() throws -> String? { - codeVerifier - } - - func storeCodeVerifier(_ code: String) throws { - codeVerifier = code - } - - func deleteCodeVerifier() throws { - codeVerifier = nil - } -} diff --git a/Tests/GoTrueTests/Mocks/Mocks.swift b/Tests/GoTrueTests/Mocks/Mocks.swift new file mode 100644 index 00000000..b0b49972 --- /dev/null +++ b/Tests/GoTrueTests/Mocks/Mocks.swift @@ -0,0 +1,96 @@ +// +// File.swift +// +// +// Created by Guilherme Souza on 27/10/23. +// + +import Foundation +import XCTestDynamicOverlay +@_spi(Internal) import _Helpers + +@testable import GoTrue + +let clientURL = URL(string: "http://localhost:54321/auth/v1")! + +extension CodeVerifierStorage { + static let mock = Self( + getCodeVerifier: unimplemented("getCodeVerifier"), + storeCodeVerifier: unimplemented("storeCodeVerifier"), + deleteCodeVerifier: unimplemented("deleteCodeVerifier") + ) +} + +extension SessionManager { + static let mock = Self( + session: unimplemented("session"), + update: unimplemented("update"), + remove: unimplemented("remove") + ) +} + +extension EventEmitter { + static let mock = Self( + attachListener: unimplemented("attachListener"), + emit: unimplemented("emit") + ) + + static let noop = Self( + attachListener: { (UUID(), AsyncStream.makeStream().stream) }, + emit: { _, _ in } + ) +} + +extension SessionStorage { + static let mock = Self( + getSession: unimplemented("getSession"), + storeSession: unimplemented("storeSession"), + deleteSession: unimplemented("deleteSession") + ) +} + +extension SessionRefresher { + static let mock = Self(refreshSession: unimplemented("refreshSession")) +} + +extension Dependencies { + static let mock = Dependencies( + configuration: GoTrueClient.Configuration(url: clientURL), + sessionManager: .mock, + api: APIClient(), + eventEmitter: .mock, + sessionStorage: .mock, + sessionRefresher: .mock, + codeVerifierStorage: .mock + ) +} + +func withDependencies( + _ mutation: (inout Dependencies) -> Void, + operation: () async throws -> Void +) async rethrows { + let current = Dependencies.current.value ?? .mock + var copy = current + mutation(©) + Dependencies.current.withValue { $0 = copy } + defer { Dependencies.current.setValue(current) } + try await operation() +} + +extension Session { + static let validSession = Session( + accessToken: "accesstoken", + tokenType: "bearer", + expiresIn: 120, + refreshToken: "refreshtoken", + user: User(fromMockNamed: "user") + ) + + static let expiredSession = Session( + accessToken: "accesstoken", + tokenType: "bearer", + expiresIn: 60, + refreshToken: "refreshtoken", + user: User(fromMockNamed: "user") + ) +} diff --git a/Tests/GoTrueTests/RequestsTests.swift b/Tests/GoTrueTests/RequestsTests.swift index 54a5315b..ca2cc8de 100644 --- a/Tests/GoTrueTests/RequestsTests.swift +++ b/Tests/GoTrueTests/RequestsTests.swift @@ -17,88 +17,122 @@ 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" - ) + await withDependencies { + $0.sessionManager.remove = {} + } operation: { + 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" - ) + + await withDependencies { + $0.sessionManager.remove = {} + } operation: { + 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" - ) + + await withDependencies { + $0.sessionManager.remove = {} + } operation: { + await assert { + try await sut.signIn( + email: "example@mail.com", + password: "the.pass" + ) + } } } func testSignInWithPhoneAndPassword() async { let sut = makeSUT() - await assert { - try await sut.signIn( - phone: "+1 202-918-2132", - password: "the.pass" - ) + + await withDependencies { + $0.sessionManager.remove = {} + } operation: { + await assert { + try await sut.signIn( + phone: "+1 202-918-2132", + password: "the.pass" + ) + } } } 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: GoTrueMetaSecurity( - captchaToken: "captcha-token" + + await withDependencies { + $0.sessionManager.remove = {} + } operation: { + await assert { + try await sut.signInWithIdToken( + credentials: OpenIDConnectCredentials( + provider: .apple, + idToken: "id-token", + accessToken: "access-token", + nonce: "nonce", + gotrueMetaSecurity: GoTrueMetaSecurity( + 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" - ) + + await withDependencies { + $0.sessionManager.remove = {} + } operation: { + 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" - ) + + await withDependencies { + $0.sessionManager.remove = {} + } operation: { + await assert { + try await sut.signInWithOTP( + phone: "+1 202-918-2132", + shouldCreateUser: true, + data: ["custom_key": .string("custom_value")], + captchaToken: "dummy-captcha" + ) + } } } @@ -133,7 +167,9 @@ final class RequestsTests: XCTestCase { }) try await withDependencies { + $0.sessionManager.update = { _ in } $0.sessionStorage.storeSession = { _ in } + $0.codeVerifierStorage.getCodeVerifier = { nil } $0.eventEmitter = .live } operation: { let url = URL( @@ -155,17 +191,22 @@ final class RequestsTests: XCTestCase { 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 let error as URLError { - XCTAssertEqual(error.code, .badURL) - } catch { - XCTFail("Unexpected error thrown: \(error.localizedDescription)") + await withDependencies { + $0.codeVerifierStorage.getCodeVerifier = { nil } + } operation: { + 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 let error as URLError { + XCTAssertEqual(error.code, .badURL) + } catch { + XCTFail("Unexpected error thrown: \(error.localizedDescription)") + } } } @@ -173,8 +214,8 @@ final class RequestsTests: XCTestCase { let sut = makeSUT() await withDependencies { - $0.sessionStorage.getSession = { - .init(session: .validSession) + $0.sessionManager.session = { + .validSession } } operation: { let accessToken = @@ -200,10 +241,8 @@ final class RequestsTests: XCTestCase { func testSignOut() async { let sut = makeSUT() await withDependencies { - $0.sessionStorage.getSession = { - .init(session: .validSession) - } - $0.eventEmitter = .live + $0.sessionManager.session = { .validSession } + $0.eventEmitter = .noop } operation: { await assert { try await sut.signOut() @@ -213,26 +252,36 @@ final class RequestsTests: XCTestCase { 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" - ) + + await withDependencies { + $0.sessionManager.remove = {} + } operation: { + 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" - ) + + await withDependencies { + $0.sessionManager.remove = {} + } operation: { + await assert { + try await sut.verifyOTP( + phone: "+1 202-918-2132", + token: "123456", + type: .sms, + captchaToken: "captcha-token" + ) + } } } @@ -240,8 +289,8 @@ final class RequestsTests: XCTestCase { let sut = makeSUT() await withDependencies { - $0.sessionStorage.getSession = { - .init(session: .validSession) + $0.sessionManager.session = { + .validSession } } operation: { await assert { @@ -285,14 +334,9 @@ final class RequestsTests: XCTestCase { testName: String = #function, line: UInt = #line ) -> GoTrueClient { - var storage = SessionStorage.mock - storage.deleteSession = {} - let encoder = JSONEncoder.goTrue encoder.outputFormatting = .sortedKeys - let sessionManager = DefaultSessionManager() - let configuration = GoTrueClient.Configuration( url: clientURL, headers: ["apikey": "dummy.api.key"], @@ -316,31 +360,11 @@ final class RequestsTests: XCTestCase { return GoTrueClient( configuration: configuration, - sessionManager: sessionManager, - codeVerifierStorage: CodeVerifierStorageMock(), + sessionManager: .mock, + codeVerifierStorage: .mock, api: api, eventEmitter: .mock, - sessionStorage: storage + sessionStorage: .mock ) } } - -let clientURL = URL(string: "http://localhost:54321/auth/v1")! - -extension Session { - static let validSession = Session( - accessToken: "accesstoken", - tokenType: "bearer", - expiresIn: 120, - refreshToken: "refreshtoken", - user: User(fromMockNamed: "user") - ) - - static let expiredSession = Session( - accessToken: "accesstoken", - tokenType: "bearer", - expiresIn: 60, - refreshToken: "refreshtoken", - user: User(fromMockNamed: "user") - ) -} diff --git a/Tests/GoTrueTests/SessionManagerTests.swift b/Tests/GoTrueTests/SessionManagerTests.swift index e2256543..93dbc59f 100644 --- a/Tests/GoTrueTests/SessionManagerTests.swift +++ b/Tests/GoTrueTests/SessionManagerTests.swift @@ -22,7 +22,7 @@ final class SessionManagerTests: XCTestCase { await withDependencies { $0.sessionStorage.getSession = { nil } } operation: { - let sut = DefaultSessionManager() + let sut = SessionManager.live do { _ = try await sut.session() @@ -40,7 +40,7 @@ final class SessionManagerTests: XCTestCase { .init(session: .validSession) } } operation: { - let sut = DefaultSessionManager() + let sut = SessionManager.live let session = try await sut.session() XCTAssertEqual(session, .validSession) @@ -70,7 +70,7 @@ final class SessionManagerTests: XCTestCase { return await refreshSessionStream.first { _ in true } ?? .empty } } operation: { - let sut = DefaultSessionManager() + let sut = SessionManager.live // Fire N tasks and call sut.session() let tasks = (0..<10).map { _ in @@ -98,42 +98,3 @@ final class SessionManagerTests: XCTestCase { } } } - -extension EventEmitter { - static let mock = Self( - attachListener: unimplemented("attachListener"), emit: unimplemented("emit")) -} - -extension SessionStorage { - static let mock = Self( - getSession: unimplemented("getSession"), - storeSession: unimplemented("storeSession"), - deleteSession: unimplemented("deleteSession") - ) -} - -extension SessionRefresher { - static let mock = Self(refreshSession: unimplemented("refreshSession")) -} - -extension Dependencies { - static let mock = Dependencies( - configuration: GoTrueClient.Configuration(url: clientURL), - sessionManager: DefaultSessionManager(), - api: APIClient(), - eventEmitter: .mock, - sessionStorage: .mock, - sessionRefresher: .mock - ) -} - -func withDependencies(_ mutation: (inout Dependencies) -> Void, operation: () async throws -> Void) - async rethrows -{ - let current = Dependencies.current.value ?? .mock - var copy = current - mutation(©) - Dependencies.current.withValue { $0 = copy } - defer { Dependencies.current.setValue(current) } - try await operation() -}