diff --git a/Sources/Auth/AuthAdmin.swift b/Sources/Auth/AuthAdmin.swift index 4ad33957..3b6252e1 100644 --- a/Sources/Auth/AuthAdmin.swift +++ b/Sources/Auth/AuthAdmin.swift @@ -9,13 +9,11 @@ import Foundation @_spi(Internal) import _Helpers public actor AuthAdmin { - private var configuration: AuthClient.Configuration { - Dependencies.current.value!.configuration - } + @Dependency(\.configuration) + private var configuration: AuthClient.Configuration - private var api: APIClient { - Dependencies.current.value!.api - } + @Dependency(\.api) + private var api: APIClient /// Delete a user. Requires `service_role` key. /// - Parameter id: The id of the user you want to delete. diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 58a91504..1e04fd92 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -56,33 +56,26 @@ public actor AuthClient { } } - private var configuration: Configuration { - Dependencies.current.value!.configuration - } + @Dependency(\.configuration) + private var configuration: Configuration - private var api: APIClient { - Dependencies.current.value!.api - } + @Dependency(\.api) + private var api: APIClient - private var sessionManager: any SessionManager { - Dependencies.current.value!.sessionManager - } + @Dependency(\.eventEmitter) + private var eventEmitter: EventEmitter - private var codeVerifierStorage: CodeVerifierStorage { - Dependencies.current.value!.codeVerifierStorage - } + @Dependency(\.sessionManager) + private var sessionManager: SessionManager - private var eventEmitter: any EventEmitter { - Dependencies.current.value!.eventEmitter - } + @Dependency(\.codeVerifierStorage) + private var codeVerifierStorage: CodeVerifierStorage - private var currentDate: @Sendable () -> Date { - Dependencies.current.value!.currentDate - } + @Dependency(\.currentDate) + private var currentDate: @Sendable () -> Date - private var logger: (any SupabaseLogger)? { - Dependencies.current.value!.logger - } + @Dependency(\.logger) + private var logger: (any SupabaseLogger)? /// Returns the session, refreshing it if necessary. /// @@ -141,17 +134,20 @@ public actor AuthClient { /// - Parameters: /// - configuration: The client configuration. public init(configuration: Configuration) { - let api = APIClient.live(http: HTTPClient( - logger: configuration.logger, - fetchHandler: configuration.fetch - )) + let api = APIClient.live( + configuration: configuration, + http: HTTPClient( + logger: configuration.logger, + fetchHandler: configuration.fetch + ) + ) self.init( configuration: configuration, - sessionManager: DefaultSessionManager.shared, + sessionManager: .live, codeVerifierStorage: .live, api: api, - eventEmitter: DefaultEventEmitter.shared, + eventEmitter: .live, sessionStorage: .live, logger: configuration.logger ) @@ -160,31 +156,29 @@ public actor AuthClient { /// This internal initializer is here only for easy injecting mock instances when testing. init( configuration: Configuration, - sessionManager: any SessionManager, + sessionManager: SessionManager, codeVerifierStorage: CodeVerifierStorage, api: APIClient, - eventEmitter: any EventEmitter, + eventEmitter: EventEmitter, sessionStorage: SessionStorage, logger: (any SupabaseLogger)? ) { mfa = AuthMFA() admin = AuthAdmin() - Dependencies.current.setValue( - Dependencies( - configuration: configuration, - sessionManager: sessionManager, - api: api, - eventEmitter: eventEmitter, - sessionStorage: sessionStorage, - sessionRefresher: SessionRefresher( - refreshSession: { [weak self] in - try await self?.refreshSession(refreshToken: $0) ?? .empty - } - ), - codeVerifierStorage: codeVerifierStorage, - logger: logger - ) + Current = Dependencies( + configuration: configuration, + sessionManager: sessionManager, + api: api, + eventEmitter: eventEmitter, + sessionStorage: sessionStorage, + sessionRefresher: SessionRefresher( + refreshSession: { [weak self] in + try await self?.refreshSession(refreshToken: $0) ?? .empty + } + ), + codeVerifierStorage: codeVerifierStorage, + logger: logger ) } @@ -310,7 +304,11 @@ public actor AuthClient { /// Log in an existing user with an email and password. @discardableResult - public func signIn(email: String, password: String, captchaToken: String? = nil) async throws -> Session { + public func signIn( + email: String, + password: String, + captchaToken: String? = nil + ) async throws -> Session { try await _signIn( request: .init( path: "/token", @@ -329,7 +327,11 @@ public actor AuthClient { /// Log in an existing user with a phone and password. @discardableResult - public func signIn(phone: String, password: String, captchaToken: String? = nil) async throws -> Session { + public func signIn( + phone: String, + password: String, + captchaToken: String? = nil + ) async throws -> Session { try await _signIn( request: .init( path: "/token", @@ -931,7 +933,7 @@ public actor AuthClient { private func emitInitialSession(forToken token: ObservationToken) async { let session = try? await session - eventEmitter.emit(.initialSession, session: session, token: token) + eventEmitter.emit(.initialSession, session, token) } private func prepareForPKCE() -> (codeChallenge: String?, codeChallengeMethod: String?) { diff --git a/Sources/Auth/AuthMFA.swift b/Sources/Auth/AuthMFA.swift index ce630052..f1c725c0 100644 --- a/Sources/Auth/AuthMFA.swift +++ b/Sources/Auth/AuthMFA.swift @@ -3,21 +3,17 @@ import Foundation /// Contains the full multi-factor authentication API. public actor AuthMFA { - private var api: APIClient { - Dependencies.current.value!.api - } + @Dependency(\.api) + private var api: APIClient - private var sessionManager: any SessionManager { - Dependencies.current.value!.sessionManager - } + @Dependency(\.configuration) + private var configuration: AuthClient.Configuration - private var configuration: AuthClient.Configuration { - Dependencies.current.value!.configuration - } + @Dependency(\.sessionManager) + private var sessionManager: SessionManager - private var eventEmitter: any EventEmitter { - Dependencies.current.value!.eventEmitter - } + @Dependency(\.eventEmitter) + private var eventEmitter: EventEmitter /// Starts the enrollment process for a new Multi-Factor Authentication (MFA) factor. This method /// creates a new `unverified` factor. diff --git a/Sources/Auth/Internal/APIClient.swift b/Sources/Auth/Internal/APIClient.swift index b32ee075..b9277265 100644 --- a/Sources/Auth/Internal/APIClient.swift +++ b/Sources/Auth/Internal/APIClient.swift @@ -6,12 +6,11 @@ struct APIClient: Sendable { } extension APIClient { - static func live(http: HTTPClient) -> Self { - var configuration: AuthClient.Configuration { - Dependencies.current.value!.configuration - } - - return APIClient( + static func live( + configuration: AuthClient.Configuration, + http: HTTPClient + ) -> Self { + APIClient( execute: { request in var request = request request.headers.merge(configuration.headers) { r, _ in r } @@ -45,7 +44,9 @@ extension APIClient { extension APIClient { @discardableResult func authorizedExecute(_ request: Request) async throws -> Response { - let session = try await Dependencies.current.value!.sessionManager.session() + @Dependency(\.sessionManager) var sessionManager + + let session = try await sessionManager.session() var request = request request.headers["Authorization"] = "Bearer \(session.accessToken)" diff --git a/Sources/Auth/Internal/CodeVerifierStorage.swift b/Sources/Auth/Internal/CodeVerifierStorage.swift index 0ffca5df..07d4b326 100644 --- a/Sources/Auth/Internal/CodeVerifierStorage.swift +++ b/Sources/Auth/Internal/CodeVerifierStorage.swift @@ -8,10 +8,8 @@ struct CodeVerifierStorage: Sendable { } extension CodeVerifierStorage { - static var live: Self = { - var localStorage: any AuthLocalStorage { - Dependencies.current.value!.configuration.localStorage - } + static let live: Self = { + @Dependency(\.configuration.localStorage) var localStorage let key = "supabase.code-verifier" diff --git a/Sources/Auth/Internal/Dependencies.swift b/Sources/Auth/Internal/Dependencies.swift index fc2c03ee..2950a1ea 100644 --- a/Sources/Auth/Internal/Dependencies.swift +++ b/Sources/Auth/Internal/Dependencies.swift @@ -3,15 +3,42 @@ import ConcurrencyExtras import Foundation struct Dependencies: Sendable { - static let current = LockIsolated(Dependencies?.none) - var configuration: AuthClient.Configuration - var sessionManager: any SessionManager + var sessionManager: SessionManager var api: APIClient - var eventEmitter: any EventEmitter + var eventEmitter: EventEmitter var sessionStorage: SessionStorage var sessionRefresher: SessionRefresher var codeVerifierStorage: CodeVerifierStorage var currentDate: @Sendable () -> Date = { Date() } var logger: (any SupabaseLogger)? } + +private let _Current = LockIsolated(nil) +var Current: Dependencies { + get { + guard let instance = _Current.value else { + fatalError("Current should be set before usage.") + } + + return instance + } + set { + _Current.withValue { Current in + Current = newValue + } + } +} + +@propertyWrapper +struct Dependency { + var wrappedValue: Value { + Current[keyPath: keyPath] + } + + let keyPath: KeyPath + + init(_ keyPath: KeyPath) { + self.keyPath = keyPath + } +} diff --git a/Sources/Auth/Internal/EventEmitter.swift b/Sources/Auth/Internal/EventEmitter.swift index 12b50833..50545bf4 100644 --- a/Sources/Auth/Internal/EventEmitter.swift +++ b/Sources/Auth/Internal/EventEmitter.swift @@ -2,16 +2,16 @@ import ConcurrencyExtras import Foundation @_spi(Internal) import _Helpers -protocol EventEmitter: Sendable { - func attachListener( +struct EventEmitter: Sendable { + var attachListener: @Sendable ( _ listener: @escaping AuthStateChangeListener ) -> ObservationToken - func emit( + var emit: @Sendable ( _ event: AuthChangeEvent, - session: Session?, - token: ObservationToken? - ) + _ session: Session?, + _ token: ObservationToken? + ) -> Void } extension EventEmitter { @@ -19,43 +19,36 @@ extension EventEmitter { _ event: AuthChangeEvent, session: Session? ) { - emit(event, session: session, token: nil) + emit(event, session, nil) } } -final class DefaultEventEmitter: EventEmitter { - static let shared = DefaultEventEmitter() - - private init() {} - - let emitter = _Helpers.EventEmitter<(AuthChangeEvent, Session?)?>( - initialEvent: nil, - emitsLastEventWhenAttaching: false - ) - - func attachListener( - _ listener: @escaping AuthStateChangeListener - ) -> ObservationToken { - emitter.attach { event in - guard let event else { return } - listener(event.0, event.1) - } - } - - func emit( - _ event: AuthChangeEvent, - session: Session?, - token: ObservationToken? = nil - ) { - NotificationCenter.default.post( - name: AuthClient.didChangeAuthStateNotification, - object: nil, - userInfo: [ - AuthClient.authChangeEventInfoKey: event, - AuthClient.authChangeSessionInfoKey: session as Any, - ] +extension EventEmitter { + static let live: EventEmitter = { + let emitter = _Helpers.EventEmitter<(AuthChangeEvent, Session?)?>( + initialEvent: nil, + emitsLastEventWhenAttaching: false ) - emitter.emit((event, session), to: token) - } + return EventEmitter( + attachListener: { listener in + emitter.attach { event in + guard let event else { return } + listener(event.0, event.1) + } + }, + emit: { event, session, token in + NotificationCenter.default.post( + name: AuthClient.didChangeAuthStateNotification, + object: nil, + userInfo: [ + AuthClient.authChangeEventInfoKey: event, + AuthClient.authChangeSessionInfoKey: session as Any, + ] + ) + + emitter.emit((event, session), to: token) + } + ) + }() } diff --git a/Sources/Auth/Internal/SessionManager.swift b/Sources/Auth/Internal/SessionManager.swift index ae1d65d1..8cbb65b5 100644 --- a/Sources/Auth/Internal/SessionManager.swift +++ b/Sources/Auth/Internal/SessionManager.swift @@ -5,32 +5,38 @@ struct SessionRefresher: Sendable { var refreshSession: @Sendable (_ refreshToken: String) async throws -> Session } -protocol SessionManager: Sendable { - func session(shouldValidateExpiration: Bool) async throws -> Session - func update(_ session: Session) async throws -> Void - func remove() async +struct SessionManager: Sendable { + var session: @Sendable (_ shouldValidateExpiration: Bool) async throws -> Session + var update: @Sendable (_ session: Session) async throws -> Void + var remove: @Sendable () async -> Void } extension SessionManager { - func session() async throws -> Session { - try await session(shouldValidateExpiration: true) + func session(shouldValidateExpiration: Bool = true) async throws -> Session { + try await session(shouldValidateExpiration) } } -actor DefaultSessionManager: SessionManager { - static let shared = DefaultSessionManager() - - private init() {} +extension SessionManager { + static let live: SessionManager = { + let manager = _DefaultSessionManager() + + return SessionManager( + session: { try await manager.session(shouldValidateExpiration: $0) }, + update: { try await manager.update($0) }, + remove: { await manager.remove() } + ) + }() +} +private actor _DefaultSessionManager { private var task: Task? - private var storage: SessionStorage { - Dependencies.current.value!.sessionStorage - } + @Dependency(\.sessionStorage) + private var storage: SessionStorage - private var sessionRefresher: SessionRefresher { - Dependencies.current.value!.sessionRefresher - } + @Dependency(\.sessionRefresher) + private var sessionRefresher: SessionRefresher func session(shouldValidateExpiration: Bool) async throws -> Session { if let task { diff --git a/Sources/Auth/Internal/SessionStorage.swift b/Sources/Auth/Internal/SessionStorage.swift index f31cbe43..7e724b7a 100644 --- a/Sources/Auth/Internal/SessionStorage.swift +++ b/Sources/Auth/Internal/SessionStorage.swift @@ -30,10 +30,8 @@ struct SessionStorage: Sendable { } extension SessionStorage { - static var live: Self = { - var localStorage: any AuthLocalStorage { - Dependencies.current.value!.configuration.localStorage - } + static let live: Self = { + @Dependency(\.configuration.localStorage) var localStorage: any AuthLocalStorage return Self( getSession: { diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 44a0f5a0..5c447261 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -6,7 +6,7 @@ // import XCTest -@_spi(Internal) import _Helpers +@_spi(Internal) @testable import _Helpers import ConcurrencyExtras import TestHelpers @@ -17,9 +17,10 @@ import TestHelpers #endif final class AuthClientTests: XCTestCase { - var eventEmitter: MockEventEmitter! - var sessionManager: MockSessionManager! + var eventEmitter: Auth.EventEmitter! + var sessionManager: SessionManager! + var api: APIClient! var sut: AuthClient! override func invokeTest() { @@ -31,9 +32,9 @@ final class AuthClientTests: XCTestCase { override func setUp() { super.setUp() - eventEmitter = MockEventEmitter() - sessionManager = MockSessionManager() - sut = makeSUT() + eventEmitter = .mock + sessionManager = .mock + api = .mock } override func tearDown() { @@ -51,8 +52,11 @@ final class AuthClientTests: XCTestCase { } func testOnAuthStateChanges() async { + eventEmitter = .live let session = Session.validSession - sessionManager.returnSession = .success(session) + sessionManager.session = { @Sendable _ in session } + + sut = makeSUT() let events = LockIsolated([AuthChangeEvent]()) @@ -68,8 +72,11 @@ final class AuthClientTests: XCTestCase { } func testAuthStateChanges() async throws { + eventEmitter = .live let session = Session.validSession - sessionManager.returnSession = .success(session) + sessionManager.session = { @Sendable _ in session } + + sut = makeSUT() let stateChange = await sut.authStateChanges.first { _ in true } XCTAssertEqual(stateChange?.event, .initialSession) @@ -77,91 +84,121 @@ final class AuthClientTests: XCTestCase { } func testSignOut() async throws { - sessionManager.returnSession = .success(.validSession) - - try await withDependencies { - $0.api.execute = { _ in .stub() } - } operation: { - try await sut.signOut() + let emitReceivedEvents = LockIsolated<[AuthChangeEvent]>([]) - do { - _ = try await sut.session - } catch AuthError.sessionNotFound { - } catch { - XCTFail("Unexpected error.") + eventEmitter.emit = { @Sendable event, _, _ in + emitReceivedEvents.withValue { + $0.append(event) } + } + sessionManager.session = { @Sendable _ in .validSession } + sessionManager.remove = { @Sendable in } + api.execute = { @Sendable _ in .stub() } + + sut = makeSUT() + + try await sut.signOut() - XCTAssertEqual(eventEmitter.emitReceivedParams.map(\.0), [.signedOut]) + do { + _ = try await sut.session + } catch AuthError.sessionNotFound { + } catch { + XCTFail("Unexpected error.") } + + XCTAssertEqual(emitReceivedEvents.value, [.signedOut]) } func testSignOutWithOthersScopeShouldNotRemoveLocalSession() async throws { - sessionManager.returnSession = .success(.validSession) + let removeCalled = LockIsolated(false) + sessionManager.remove = { @Sendable in removeCalled.setValue(true) } + sessionManager.session = { @Sendable _ in .validSession } + api.execute = { @Sendable _ in .stub() } + + sut = makeSUT() - try await withDependencies { - $0.api.execute = { _ in .stub() } - } operation: { - try await sut.signOut(scope: .others) + try await sut.signOut(scope: .others) - XCTAssertFalse(sessionManager.removeCalled) - } + XCTAssertFalse(removeCalled.value) } func testSignOutShouldRemoveSessionIfUserIsNotFound() async throws { - sessionManager.returnSession = .success(.validSession) - - await withDependencies { - $0.api.execute = { _ in throw AuthError.api(AuthError.APIError(code: 404)) } - } operation: { - do { - try await sut.signOut() - } catch AuthError.api { - } catch { - XCTFail("Unexpected error: \(error)") + let emitReceivedEvents = LockIsolated<[(AuthChangeEvent, Session?)]>([]) + + eventEmitter.emit = { @Sendable event, session, _ in + emitReceivedEvents.withValue { + $0.append((event, session)) } + } - let emitedParams = eventEmitter.emitReceivedParams - let emitedEvents = emitedParams.map(\.0) - let emitedSessions = emitedParams.map(\.1) + let removeCallCount = LockIsolated(0) + sessionManager.remove = { @Sendable in + removeCallCount.withValue { $0 += 1 } + } + sessionManager.session = { @Sendable _ in .validSession } + api.execute = { @Sendable _ in throw AuthError.api(AuthError.APIError(code: 404)) } - XCTAssertEqual(emitedEvents, [.signedOut]) - XCTAssertEqual(emitedSessions.count, 1) - XCTAssertNil(emitedSessions[0]) + sut = makeSUT() - XCTAssertEqual(sessionManager.removeCallCount, 1) + do { + try await sut.signOut() + } catch AuthError.api { + } catch { + XCTFail("Unexpected error: \(error)") } + + let emitedParams = emitReceivedEvents.value + let emitedEvents = emitedParams.map(\.0) + let emitedSessions = emitedParams.map(\.1) + + XCTAssertEqual(emitedEvents, [.signedOut]) + XCTAssertEqual(emitedSessions.count, 1) + XCTAssertNil(emitedSessions[0]) + + XCTAssertEqual(removeCallCount.value, 1) } func testSignOutShouldRemoveSessionIfJWTIsInvalid() async throws { - sessionManager.returnSession = .success(.validSession) - - await withDependencies { - $0.api.execute = { _ in throw AuthError.api(AuthError.APIError(code: 401)) } - } operation: { - do { - try await sut.signOut() - } catch AuthError.api { - } catch { - XCTFail("Unexpected error: \(error)") + let emitReceivedEvents = LockIsolated<[(AuthChangeEvent, Session?)]>([]) + + eventEmitter.emit = { @Sendable event, session, _ in + emitReceivedEvents.withValue { + $0.append((event, session)) } + } - let emitedParams = eventEmitter.emitReceivedParams - let emitedEvents = emitedParams.map(\.0) - let emitedSessions = emitedParams.map(\.1) + let removeCallCount = LockIsolated(0) + sessionManager.remove = { @Sendable in + removeCallCount.withValue { $0 += 1 } + } + sessionManager.session = { @Sendable _ in .validSession } + api.execute = { @Sendable _ in throw AuthError.api(AuthError.APIError(code: 401)) } - XCTAssertEqual(emitedEvents, [.signedOut]) - XCTAssertEqual(emitedSessions.count, 1) - XCTAssertNil(emitedSessions[0]) + sut = makeSUT() - XCTAssertEqual(sessionManager.removeCallCount, 1) + do { + try await sut.signOut() + } catch AuthError.api { + } catch { + XCTFail("Unexpected error: \(error)") } + + let emitedParams = emitReceivedEvents.value + let emitedEvents = emitedParams.map(\.0) + let emitedSessions = emitedParams.map(\.1) + + XCTAssertEqual(emitedEvents, [.signedOut]) + XCTAssertEqual(emitedSessions.count, 1) + XCTAssertNil(emitedSessions[0]) + + XCTAssertEqual(removeCallCount.value, 1) } private func makeSUT() -> AuthClient { let configuration = AuthClient.Configuration( url: clientURL, headers: ["Apikey": "dummy.api.key"], - localStorage: Dependencies.localStorage, + localStorage: InMemoryLocalStorage(), logger: nil ) @@ -169,7 +206,7 @@ final class AuthClientTests: XCTestCase { configuration: configuration, sessionManager: sessionManager, codeVerifierStorage: .mock, - api: .mock, + api: api, eventEmitter: eventEmitter, sessionStorage: .mock, logger: nil diff --git a/Tests/AuthTests/Mocks/MockAPIClient.swift b/Tests/AuthTests/Mocks/MockAPIClient.swift new file mode 100644 index 00000000..22802aae --- /dev/null +++ b/Tests/AuthTests/Mocks/MockAPIClient.swift @@ -0,0 +1,15 @@ +// +// MockAPIClient.swift +// +// +// Created by Guilherme Souza on 25/03/24. +// + +@testable import Auth +import Foundation +@_spi(Internal) import _Helpers +import XCTestDynamicOverlay + +extension APIClient { + static let mock = APIClient(execute: unimplemented("APIClient.execute")) +} diff --git a/Tests/AuthTests/Mocks/MockEventEmitter.swift b/Tests/AuthTests/Mocks/MockEventEmitter.swift index 2b9cc438..6b621faa 100644 --- a/Tests/AuthTests/Mocks/MockEventEmitter.swift +++ b/Tests/AuthTests/Mocks/MockEventEmitter.swift @@ -9,30 +9,11 @@ import _Helpers @testable import Auth import ConcurrencyExtras import Foundation +import XCTestDynamicOverlay -final class MockEventEmitter: EventEmitter { - private let emitter = DefaultEventEmitter.shared - - func attachListener(_ listener: @escaping AuthStateChangeListener) - -> ObservationToken - { - emitter.attachListener(listener) - } - - private let _emitReceivedParams: LockIsolated<[(AuthChangeEvent, Session?)]> = .init([]) - var emitReceivedParams: [(AuthChangeEvent, Session?)] { - _emitReceivedParams.value - } - - func emit( - _ event: AuthChangeEvent, - session: Session?, - token: ObservationToken? = nil - ) { - _emitReceivedParams.withValue { - $0.append((event, session)) - } - - emitter.emit(event, session: session, token: token) - } +extension EventEmitter { + static let mock = EventEmitter( + attachListener: unimplemented("EventEmitter.attachListener"), + emit: unimplemented("EventEmitter.emit") + ) } diff --git a/Tests/AuthTests/Mocks/MockSessionManager.swift b/Tests/AuthTests/Mocks/MockSessionManager.swift index 10b62a34..0b28ed3a 100644 --- a/Tests/AuthTests/Mocks/MockSessionManager.swift +++ b/Tests/AuthTests/Mocks/MockSessionManager.swift @@ -6,30 +6,13 @@ // @testable import Auth -import ConcurrencyExtras import Foundation +import XCTestDynamicOverlay -final class MockSessionManager: SessionManager { - private let _returnSession = LockIsolated(Result?.none) - var returnSession: Result? { - get { _returnSession.value } - set { _returnSession.setValue(newValue) } - } - - func session(shouldValidateExpiration _: Bool) async throws -> Auth.Session { - try returnSession!.get() - } - - func update(_: Auth.Session) async throws {} - - private let _removeCallCount = LockIsolated(0) - var removeCallCount: Int { - get { _removeCallCount.value } - set { _removeCallCount.setValue(newValue) } - } - - var removeCalled: Bool { removeCallCount > 0 } - func remove() async { - _removeCallCount.withValue { $0 += 1 } - } +extension SessionManager { + static let mock = SessionManager( + session: unimplemented("SessionManager.session"), + update: unimplemented("SessionManager.update"), + remove: unimplemented("SessionManager.remove") + ) } diff --git a/Tests/AuthTests/Mocks/Mocks.swift b/Tests/AuthTests/Mocks/Mocks.swift index 0ae42563..9bc7da38 100644 --- a/Tests/AuthTests/Mocks/Mocks.swift +++ b/Tests/AuthTests/Mocks/Mocks.swift @@ -44,58 +44,16 @@ extension SessionRefresher { static let mock = Self(refreshSession: unimplemented("SessionRefresher.refreshSession")) } -extension APIClient { - static let mock = APIClient(execute: unimplemented("APIClient.execute")) -} - -struct InsecureMockLocalStorage: AuthLocalStorage { - private let defaults: UserDefaults - - init(service: String, accessGroup _: String?) { - guard let defaults = UserDefaults(suiteName: service) else { - fatalError("Unable to create defautls for service: \(service)") - } - - self.defaults = defaults - } - - func store(key: String, value: Data) throws { - print("[WARN] YOU ARE YOU WRITING TO INSECURE LOCAL STORAGE") - defaults.set(value, forKey: key) - } - - func retrieve(key: String) throws -> Data? { - print("[WARN] YOU ARE READING FROM INSECURE LOCAL STORAGE") - return defaults.data(forKey: key) - } - - func remove(key: String) throws { - print("[WARN] YOU ARE REMOVING A KEY FROM INSECURE LOCAL STORAGE") - defaults.removeObject(forKey: key) - } -} - extension Dependencies { - static let localStorage: some AuthLocalStorage = { - #if !os(Linux) && !os(Windows) - KeychainLocalStorage(service: "supabase.gotrue.swift", accessGroup: nil) - #elseif os(Windows) - WinCredLocalStorage(service: "supabase.gotrue.swift") - #else - // Only use an insecure mock when needed for testing - InsecureMockLocalStorage(service: "supabase.gotrue.swift", accessGroup: nil) - #endif - }() - static let mock = Dependencies( configuration: AuthClient.Configuration( url: clientURL, - localStorage: Self.localStorage, + localStorage: InMemoryLocalStorage(), logger: nil ), - sessionManager: MockSessionManager(), + sessionManager: .mock, api: .mock, - eventEmitter: MockEventEmitter(), + eventEmitter: .mock, sessionStorage: .mock, sessionRefresher: .mock, codeVerifierStorage: .mock, @@ -103,18 +61,6 @@ extension Dependencies { ) } -func withDependencies( - _ mutation: (inout Dependencies) throws -> Void, - operation: () async throws -> Void -) async rethrows { - let current = Dependencies.current.value ?? .mock - var copy = current - try mutation(©) - Dependencies.current.withValue { [copy] in $0 = copy } - defer { Dependencies.current.setValue(current) } - try await operation() -} - extension Session { static let validSession = Session( accessToken: "accesstoken", diff --git a/Tests/AuthTests/RequestsTests.swift b/Tests/AuthTests/RequestsTests.swift index dc33d450..4c5851b4 100644 --- a/Tests/AuthTests/RequestsTests.swift +++ b/Tests/AuthTests/RequestsTests.swift @@ -18,12 +18,13 @@ import XCTest struct UnimplementedError: Error {} final class RequestsTests: XCTestCase { - var sessionManager: MockSessionManager! + var sessionManager: SessionManager! override func setUp() { super.setUp() - sessionManager = MockSessionManager() + sessionManager = .mock + sessionManager.remove = { @Sendable in } } func testSignUpWithEmailAndPassword() async { @@ -157,53 +158,50 @@ final class RequestsTests: XCTestCase { let currentDate = Date() - try await withDependencies { - $0.sessionStorage.storeSession = { _ in } - $0.codeVerifierStorage.getCodeVerifier = { nil } - $0.currentDate = { currentDate } - } operation: { - 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) - } + Current.sessionManager = .live + Current.sessionStorage.storeSession = { _ in } + Current.codeVerifierStorage.getCodeVerifier = { nil } + Current.currentDate = { 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() - 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" - )! + Current.codeVerifierStorage.getCodeVerifier = { nil } - do { - _ = try await sut.session(from: url) - } catch let error as URLError { - XCTAssertEqual(error.code, .badURL) - } catch { - XCTFail("Unexpected error thrown: \(error.localizedDescription)") - } + 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)") } } func testSetSessionWithAFutureExpirationDate() async throws { - sessionManager.returnSession = .success(.validSession) + sessionManager.session = { @Sendable _ in .validSession } let sut = makeSUT() @@ -227,7 +225,7 @@ final class RequestsTests: XCTestCase { } func testSignOut() async { - sessionManager.returnSession = .success(.validSession) + sessionManager.session = { @Sendable _ in .validSession } let sut = makeSUT() @@ -237,7 +235,7 @@ final class RequestsTests: XCTestCase { } func testSignOutWithLocalScope() async { - sessionManager.returnSession = .success(.validSession) + sessionManager.session = { @Sendable _ in .validSession } let sut = makeSUT() @@ -247,7 +245,7 @@ final class RequestsTests: XCTestCase { } func testSignOutWithOthersScope() async { - sessionManager.returnSession = .success(.validSession) + sessionManager.session = { @Sendable _ in .validSession } let sut = makeSUT() @@ -284,7 +282,7 @@ final class RequestsTests: XCTestCase { } func testUpdateUser() async throws { - sessionManager.returnSession = .success(.validSession) + sessionManager.session = { @Sendable _ in .validSession } let sut = makeSUT() @@ -347,7 +345,7 @@ final class RequestsTests: XCTestCase { } func testReauthenticate() async { - sessionManager.returnSession = .success(.validSession) + sessionManager.session = { @Sendable _ in .validSession } let sut = makeSUT() @@ -357,7 +355,7 @@ final class RequestsTests: XCTestCase { } func testUnlinkIdentity() async { - sessionManager.returnSession = .success(.validSession) + sessionManager.session = { @Sendable _ in .validSession } let sut = makeSUT() @@ -419,14 +417,17 @@ final class RequestsTests: XCTestCase { } ) - let api = APIClient.live(http: HTTPClient(logger: nil, fetchHandler: configuration.fetch)) + let api = APIClient.live( + configuration: configuration, + http: HTTPClient(logger: nil, fetchHandler: configuration.fetch) + ) return AuthClient( configuration: configuration, sessionManager: sessionManager, codeVerifierStorage: .mock, api: api, - eventEmitter: MockEventEmitter(), + eventEmitter: .live, sessionStorage: .mock, logger: nil ) diff --git a/Tests/AuthTests/SessionManagerTests.swift b/Tests/AuthTests/SessionManagerTests.swift index 782d06f1..7c6e3a0c 100644 --- a/Tests/AuthTests/SessionManagerTests.swift +++ b/Tests/AuthTests/SessionManagerTests.swift @@ -16,36 +16,32 @@ final class SessionManagerTests: XCTestCase { override func setUp() { super.setUp() - Dependencies.current.setValue(.mock) + Current = .mock } func testSession_shouldFailWithSessionNotFound() async { - await withDependencies { - $0.sessionStorage.getSession = { nil } - } operation: { - let sut = DefaultSessionManager.shared - - do { - _ = try await sut.session() - XCTFail("Expected a \(AuthError.sessionNotFound) failure") - } catch AuthError.sessionNotFound { - } catch { - XCTFail("Unexpected error \(error)") - } + Current.sessionStorage.getSession = { nil } + + let sut = SessionManager.live + + do { + _ = try await sut.session() + XCTFail("Expected a \(AuthError.sessionNotFound) failure") + } catch AuthError.sessionNotFound { + } catch { + XCTFail("Unexpected error \(error)") } } func testSession_shouldReturnValidSession() async throws { - try await withDependencies { - $0.sessionStorage.getSession = { - .init(session: .validSession) - } - } operation: { - let sut = DefaultSessionManager.shared - - let session = try await sut.session() - XCTAssertEqual(session, .validSession) + Current.sessionStorage.getSession = { + .init(session: .validSession) } + + let sut = SessionManager.live + + let session = try await sut.session() + XCTAssertEqual(session, .validSession) } func testSession_shouldRefreshSession_whenCurrentSessionExpired() async throws { @@ -53,50 +49,47 @@ final class SessionManagerTests: XCTestCase { let validSession = Session.validSession let storeSessionCallCount = LockIsolated(0) - let refreshSessionCallCount = ActorIsolated(0) + let refreshSessionCallCount = LockIsolated(0) let (refreshSessionStream, refreshSessionContinuation) = AsyncStream.makeStream() - try await withDependencies { - $0.sessionStorage.getSession = { - .init(session: currentSession) - } - $0.sessionStorage.storeSession = { _ in - storeSessionCallCount.withValue { - $0 += 1 - } - } - $0.sessionRefresher.refreshSession = { _ in - await refreshSessionCallCount.withValue { $0 += 1 } - return await refreshSessionStream.first { _ in true } ?? .empty - } - } operation: { - let sut = DefaultSessionManager.shared - - // Fire N tasks and call sut.session() - let tasks = (0 ..< 10).map { _ in - Task.detached { - try await sut.session() - } + Current.sessionStorage.getSession = { + .init(session: currentSession) + } + Current.sessionStorage.storeSession = { _ in + storeSessionCallCount.withValue { + $0 += 1 } + } + Current.sessionRefresher.refreshSession = { _ in + refreshSessionCallCount.withValue { $0 += 1 } + return await refreshSessionStream.first { _ in true } ?? .empty + } - await Task.megaYield() - - refreshSessionContinuation.yield(validSession) - refreshSessionContinuation.finish() + let sut = SessionManager.live - // Await for all tasks to complete. - var result: [Result] = [] - for task in tasks { - let value = await task.result - result.append(value) + // Fire N tasks and call sut.session() + let tasks = (0 ..< 10).map { _ in + Task.detached { + try await sut.session() } + } + + await Task.megaYield() - // Verify that refresher and storage was called only once. - let refreshSessionCallCount = await refreshSessionCallCount.value - XCTAssertEqual(refreshSessionCallCount, 1) - XCTAssertEqual(storeSessionCallCount.value, 1) - XCTAssertEqual(try result.map { try $0.get() }, (0 ..< 10).map { _ in validSession }) + refreshSessionContinuation.yield(validSession) + refreshSessionContinuation.finish() + + // Await for all tasks to complete. + var result: [Result] = [] + for task in tasks { + let value = await task.result + result.append(value) } + + // Verify that refresher and storage was called only once. + XCTAssertEqual(refreshSessionCallCount.value, 1) + XCTAssertEqual(storeSessionCallCount.value, 1) + XCTAssertEqual(try result.map { try $0.get() }, (0 ..< 10).map { _ in validSession }) } } diff --git a/Tests/PostgRESTTests/BuildURLRequestTests.swift b/Tests/PostgRESTTests/BuildURLRequestTests.swift index deeb622d..8fb55cab 100644 --- a/Tests/PostgRESTTests/BuildURLRequestTests.swift +++ b/Tests/PostgRESTTests/BuildURLRequestTests.swift @@ -166,7 +166,7 @@ final class BuildURLRequestTests: XCTestCase { try await client.from("users") .insert(User(email: "johndoe@supabase.io")) .select("id,email") - } + }, ] for testCase in testCases {