From fac8532b026c636b595af890a698c150140a3d11 Mon Sep 17 00:00:00 2001 From: Ryvolution Date: Mon, 7 Apr 2025 18:32:20 +0100 Subject: [PATCH 1/3] #146: Use supabase for authentication. --- src/main/kotlin/authentication/Auth.kt | 6 +++- src/main/kotlin/authentication/AuthState.kt | 11 +++++++ .../kotlin/authentication/Authentication.kt | 5 +-- src/main/kotlin/authentication/LocalAuth.kt | 33 +++++++++++++------ src/main/kotlin/authentication/ReadAuth.kt | 9 +++++ src/main/kotlin/authentication/RemoteAuth.kt | 20 +++-------- src/main/kotlin/login/Login.kt | 7 +++- src/main/kotlin/login/LoginPresenter.kt | 25 +++++++++++++- src/main/kotlin/login/LoginUseCase.kt | 32 ++++++++++-------- 9 files changed, 105 insertions(+), 43 deletions(-) create mode 100644 src/main/kotlin/authentication/AuthState.kt create mode 100644 src/main/kotlin/authentication/ReadAuth.kt diff --git a/src/main/kotlin/authentication/Auth.kt b/src/main/kotlin/authentication/Auth.kt index 83be45d..e256823 100644 --- a/src/main/kotlin/authentication/Auth.kt +++ b/src/main/kotlin/authentication/Auth.kt @@ -18,6 +18,10 @@ interface Auth { private val client: SupabaseClient, private val coroutineScope: CoroutineScope, ) { - fun create(): Auth = if (Secrets.USE_LOCAL_MOCKS) LocalAuth else RemoteAuth(client, coroutineScope) + private val remote by lazy { RemoteAuth(client, coroutineScope) } + + fun create(): Auth = if (Secrets.USE_LOCAL_MOCKS) LocalAuth else remote + + fun reader(): ReadAuth = if (Secrets.USE_LOCAL_MOCKS) LocalAuth else remote } } diff --git a/src/main/kotlin/authentication/AuthState.kt b/src/main/kotlin/authentication/AuthState.kt new file mode 100644 index 0000000..e0f46b2 --- /dev/null +++ b/src/main/kotlin/authentication/AuthState.kt @@ -0,0 +1,11 @@ +package authentication + +sealed interface AuthState { + data class SignIn(val user: String) : AuthState + + data object InvalidCredentials : AuthState + + data object Authenticating : AuthState + + data object InitState : AuthState +} diff --git a/src/main/kotlin/authentication/Authentication.kt b/src/main/kotlin/authentication/Authentication.kt index 3c7e7d5..1f849b5 100644 --- a/src/main/kotlin/authentication/Authentication.kt +++ b/src/main/kotlin/authentication/Authentication.kt @@ -7,6 +7,7 @@ import org.koin.dsl.module val authenticationModule = module { - factory { Auth.Factory(get(), CoroutineScope(SupervisorJob() + get().io)) } - factory { get().create() } + single { Auth.Factory(get(), CoroutineScope(SupervisorJob() + get().io)) } + single { get().create() } + single { get().reader() } } diff --git a/src/main/kotlin/authentication/LocalAuth.kt b/src/main/kotlin/authentication/LocalAuth.kt index e9fe1a9..eda92fb 100644 --- a/src/main/kotlin/authentication/LocalAuth.kt +++ b/src/main/kotlin/authentication/LocalAuth.kt @@ -1,31 +1,44 @@ package authentication import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlin.time.Duration.Companion.seconds -object LocalAuth : Auth { - private var isLoggedIn: Boolean = false +object LocalAuth : Auth, ReadAuth { + private val _state: MutableStateFlow = MutableStateFlow(AuthState.InitState) + val state: StateFlow = _state.asStateFlow() override suspend fun login( email: String, password: String, ) { delay(3.seconds) - println("local Auth login") - isLoggedIn = (email in validUsers && password == PASSWORD) + val state = + if (email in validUsers && password == PASSWORD) + { + AuthState.SignIn( + email, + ) + } else { + AuthState.InvalidCredentials + } + this._state.value = state } override suspend fun logout() { - println("local Auth logout") - isLoggedIn = false + _state.value = AuthState.InitState } - override suspend fun isLoggedIn(): Boolean { - println("local local is logged in") - return isLoggedIn - } + override suspend fun isLoggedIn(): Boolean = _state.value is AuthState.SignIn private val validUsers = listOf("Robert", "Dunia", "Tom", "Max", "Casper", "Ed", "Kai", "Laura", "Niamh", "Sofia") private const val PASSWORD = "ILoveRoky" + + override fun state(): Flow = state + + override fun getState(): AuthState = state.value } diff --git a/src/main/kotlin/authentication/ReadAuth.kt b/src/main/kotlin/authentication/ReadAuth.kt new file mode 100644 index 0000000..28f0150 --- /dev/null +++ b/src/main/kotlin/authentication/ReadAuth.kt @@ -0,0 +1,9 @@ +package authentication + +import kotlinx.coroutines.flow.Flow + +interface ReadAuth { + fun state(): Flow + + fun getState(): AuthState +} diff --git a/src/main/kotlin/authentication/RemoteAuth.kt b/src/main/kotlin/authentication/RemoteAuth.kt index 959b38a..c85eb69 100644 --- a/src/main/kotlin/authentication/RemoteAuth.kt +++ b/src/main/kotlin/authentication/RemoteAuth.kt @@ -5,16 +5,16 @@ import io.github.jan.supabase.auth.auth import io.github.jan.supabase.auth.providers.builtin.Email import io.github.jan.supabase.auth.status.SessionStatus import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch class RemoteAuth( private val client: SupabaseClient, private val scope: CoroutineScope, -) : Auth { +) : Auth, ReadAuth { private val _state: MutableStateFlow = MutableStateFlow(AuthState.InitState) val state: StateFlow = _state.asStateFlow() @@ -22,7 +22,6 @@ class RemoteAuth( scope.launch { client.auth.sessionStatus.collect { _state.value = it.toRokyState() - println("Current State: ${state.value}") } } } @@ -46,10 +45,7 @@ class RemoteAuth( println("Remote Auth logout") } - override suspend fun isLoggedIn(): Boolean { - println("Remote Auth is logged in") - return state.value is AuthState.SignIn - } + override suspend fun isLoggedIn(): Boolean = state.value is AuthState.SignIn companion object { fun SessionStatus.toRokyState(): AuthState = @@ -62,14 +58,8 @@ class RemoteAuth( } } } -} - -sealed interface AuthState { - data class SignIn(val user: String) : AuthState - - data object InvalidCredentials : AuthState - data object Authenticating : AuthState + override fun state(): Flow = state - data object InitState : AuthState + override fun getState(): AuthState = state.value } diff --git a/src/main/kotlin/login/Login.kt b/src/main/kotlin/login/Login.kt index 31526a8..893e3e7 100644 --- a/src/main/kotlin/login/Login.kt +++ b/src/main/kotlin/login/Login.kt @@ -7,7 +7,12 @@ val loginModules = module { scope { scoped { - LoginPresenter(windowScope = get().windowScope, dispatchers = get(), login = get()) + LoginPresenter( + windowScope = get().windowScope, + dispatchers = get(), + login = get(), + authState = get(), + ) } scopedOf(::LoginUseCase) } diff --git a/src/main/kotlin/login/LoginPresenter.kt b/src/main/kotlin/login/LoginPresenter.kt index 095c375..3a08530 100644 --- a/src/main/kotlin/login/LoginPresenter.kt +++ b/src/main/kotlin/login/LoginPresenter.kt @@ -2,6 +2,9 @@ package login import arch.Presenter import arch.RokyDispatchers +import authentication.AuthState +import authentication.AuthState.* +import authentication.ReadAuth import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -12,6 +15,7 @@ import login.LoginViewState.Idle class LoginPresenter( private val windowScope: CoroutineScope, private val login: LoginUseCase, + private val authState: ReadAuth, dispatchers: RokyDispatchers, ) : Presenter(dispatchers) { private val state: MutableStateFlow = MutableStateFlow(Idle()) @@ -20,8 +24,21 @@ class LoginPresenter( windowScope.launch(dispatchers.main) { state.collect(::show) } + windowScope.launch(dispatchers.io) { + authState.state().collect { + state.value = it.toLoginViewState() + } + } } + private fun AuthState.toLoginViewState(): LoginViewState = + when (this) { + Authenticating -> Idle(status = AUTHENTICATING) + InitState -> Idle() + InvalidCredentials -> Idle(status = LOGIN_FAILURE) + is SignIn -> Idle(status = LOGIN_SUCCESS) + } + override fun onDetach(view: LoginView) { TODO("Not yet implemented") } @@ -36,7 +53,11 @@ class LoginPresenter( with(event) { windowScope.launch(dispatchers.io) { state.value = Authenticating(username, password, AUTHENTICATING) - state.value = login(this@with) + with(login(event)) { + if (!isSuccessful) { + state.value = Idle(status = message.orEmpty()) + } + } } } @@ -44,5 +65,7 @@ class LoginPresenter( companion object { const val AUTHENTICATING = "Authenticating…" + const val LOGIN_SUCCESS = "Username and password is OK." + const val LOGIN_FAILURE = "Could not authenticate." } } diff --git a/src/main/kotlin/login/LoginUseCase.kt b/src/main/kotlin/login/LoginUseCase.kt index 3be8a5a..5fc30ca 100644 --- a/src/main/kotlin/login/LoginUseCase.kt +++ b/src/main/kotlin/login/LoginUseCase.kt @@ -2,29 +2,35 @@ package login import authentication.Auth import login.LoginEvent.Login -import login.LoginViewState.Idle class LoginUseCase( private val authenticator: Auth, ) { - suspend operator fun invoke(event: Login): LoginViewState = + suspend operator fun invoke(event: Login): LoginResult = with(event) { - val status = - when { - username.isBlank() -> ERROR_USERNAME - password.isBlank() -> ERROR_PASSWORD - else -> { - authenticator.login(username, password) - if (authenticator.isLoggedIn()) LOGIN_SUCCESS else LOGIN_FAILURE - } + when { + username.isBlank() -> LoginResult.fail(ERROR_USERNAME) + password.isBlank() -> LoginResult.fail(ERROR_PASSWORD) + else -> { + authenticator.login(username, password) + LoginResult.ok() } - return Idle(userName = event.username, password = "", status = status) + } } + data class LoginResult( + val isSuccessful: Boolean, + val message: String?, + ) { + companion object { + fun ok(): LoginResult = LoginResult(true, null) + + fun fail(message: String): LoginResult = LoginResult(false, message) + } + } + companion object { const val ERROR_USERNAME = "Username cannot be blank." const val ERROR_PASSWORD = "Password cannot be blank." - const val LOGIN_SUCCESS = "Username and password is OK." - const val LOGIN_FAILURE = "Could not authenticate." } } From c0e0beba607a2dedb008d1febfeee96c0a0aa5d8 Mon Sep 17 00:00:00 2001 From: Ryvolution Date: Wed, 9 Apr 2025 19:48:01 +0100 Subject: [PATCH 2/3] #146: update existing login tests to use supabase --- src/main/kotlin/authentication/LocalAuth.kt | 11 +++-- src/test/kotlin/login/LoginPresenterTest.kt | 30 +++++++++---- src/test/kotlin/login/LoginUseCaseTest.kt | 50 +++++++++++++-------- 3 files changed, 59 insertions(+), 32 deletions(-) diff --git a/src/main/kotlin/authentication/LocalAuth.kt b/src/main/kotlin/authentication/LocalAuth.kt index eda92fb..bc1342e 100644 --- a/src/main/kotlin/authentication/LocalAuth.kt +++ b/src/main/kotlin/authentication/LocalAuth.kt @@ -17,12 +17,11 @@ object LocalAuth : Auth, ReadAuth { ) { delay(3.seconds) val state = - if (email in validUsers && password == PASSWORD) - { - AuthState.SignIn( - email, - ) - } else { + if (email in validUsers && password == PASSWORD) { + AuthState.SignIn( + email, + ) + } else { AuthState.InvalidCredentials } this._state.value = state diff --git a/src/test/kotlin/login/LoginPresenterTest.kt b/src/test/kotlin/login/LoginPresenterTest.kt index a6fd690..ed0bab3 100644 --- a/src/test/kotlin/login/LoginPresenterTest.kt +++ b/src/test/kotlin/login/LoginPresenterTest.kt @@ -1,15 +1,19 @@ package login import arch.RokyDispatchers +import authentication.AuthState +import authentication.ReadAuth import coAnswersDelayed import io.mockk.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import login.LoginEvent.Login import login.LoginPresenter.Companion.AUTHENTICATING +import login.LoginUseCase.LoginResult import login.LoginViewState.Authenticating import login.LoginViewState.Idle import org.junit.jupiter.api.BeforeEach @@ -21,12 +25,13 @@ class LoginPresenterTest { private lateinit var scope: CoroutineScope private lateinit var logIn: LoginUseCase private lateinit var view: LoginView + private lateinit var auth: ReadAuth private lateinit var presenter: LoginPresenter @BeforeEach fun setUp() { logIn = mockk(relaxed = true) - coEvery { logIn(any()) } coAnswersDelayed { Idle() } + coEvery { logIn(any()) } coAnswersDelayed { LoginResult.fail("") } view = mockk(relaxed = true) val dispatchers: RokyDispatchers = @@ -35,7 +40,11 @@ class LoginPresenterTest { every { io } returns dispatcher } scope = CoroutineScope(dispatcher) - presenter = LoginPresenter(scope, logIn, dispatchers) + auth = mockk() + every { + auth.state() + } returns flowOf() + presenter = LoginPresenter(scope, logIn, auth, dispatchers) } @Test @@ -47,10 +56,12 @@ class LoginPresenterTest { } @Test - fun `when login, then immediately show authenticating status`() = + fun `when login, then immediately show init status`() = runTest(dispatcher) { - coEvery { logIn(any()) } coAnswersDelayed { Idle() } - +// coEvery { logIn(any()) } coAnswersDelayed { Idle() } + every { + auth.state() + } returns flowOf(AuthState.InitState) presenter.attach(view) presenter.onEvent(Login("User", "Password")) advanceUntilIdle() @@ -68,8 +79,7 @@ class LoginPresenterTest { @Test fun `when login, then show outcome of login`() = runTest(dispatcher) { - val loginFailed = Idle("User", "", "Login Failed") - coEvery { logIn(any()) } coAnswersDelayed { loginFailed } + coEvery { logIn(any()) } coAnswersDelayed { LoginResult.fail("Couldn't sign in") } presenter.attach(view) presenter.onEvent(Login("User", "Password")) @@ -82,7 +92,11 @@ class LoginPresenterTest { assertTrue(it is Authenticating) }, ) - view.show(loginFailed) + view.show( + withArg { + assertTrue(it is Idle) + }, + ) } } diff --git a/src/test/kotlin/login/LoginUseCaseTest.kt b/src/test/kotlin/login/LoginUseCaseTest.kt index 10a3ad6..6b054b6 100644 --- a/src/test/kotlin/login/LoginUseCaseTest.kt +++ b/src/test/kotlin/login/LoginUseCaseTest.kt @@ -11,8 +11,6 @@ import kotlinx.coroutines.test.runTest import login.LoginEvent.Login import login.LoginUseCase.Companion.ERROR_PASSWORD import login.LoginUseCase.Companion.ERROR_USERNAME -import login.LoginUseCase.Companion.LOGIN_FAILURE -import login.LoginUseCase.Companion.LOGIN_SUCCESS import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -31,14 +29,20 @@ class LoginUseCaseTest { fun `when username is blank, then status is username cannot be blank`() = runTest { val event = Login(username = "", password = "") - logIn(event) should haveStatus(ERROR_USERNAME) + with(logIn(event)) { + this should haveMessage(ERROR_USERNAME) + this should beFailure() + } } @Test fun `given username is present, when password is blank, then status is password cannot be blank`() = runTest { val event = Login(username = "rob", password = "") - logIn(event) should haveStatus(ERROR_PASSWORD) + with(logIn(event)) { + this should haveMessage(ERROR_PASSWORD) + this should beFailure() + } } @Test @@ -46,23 +50,33 @@ class LoginUseCaseTest { runTest { coEvery { authenticator.isLoggedIn() } coAnswersDelayed { true } val event = Login(username = "rob", password = "rob") - logIn(event) should haveStatus(LOGIN_SUCCESS) - } - - @Test - fun `given username and password is present, when credentials are invalid, then status is login failure`() = - runTest { - coEvery { authenticator.isLoggedIn() } coAnswersDelayed { false } - val event = Login(username = "rob", password = "rob") - logIn(event) should haveStatus(LOGIN_FAILURE) + logIn(event) should beSuccessful() } } -fun haveStatus(status: String) = - Matcher { +fun haveMessage(message: String) = + Matcher { + MatcherResult( + it.message == message, + { "Result had message ${it.message}, but expected $message" }, + { "Result should not have message $message" }, + ) + } + +fun beSuccessful() = + Matcher { + MatcherResult( + it.isSuccessful, + { "Result was not successful but expected it to be successful" }, + { "Result should not have been successful" }, + ) + } + +fun beFailure() = + Matcher { MatcherResult( - it.status == status, - { "View state had status ${it.status}, but expected $status" }, - { "View state should not have status $status" }, + !it.isSuccessful, + { "Result was successful but expected it to be not successful" }, + { "Result should have been successful" }, ) } From a4e5c0ccd38bc84acd3c8780b35cdd6c785de966 Mon Sep 17 00:00:00 2001 From: Ryvolution Date: Wed, 9 Apr 2025 20:10:51 +0100 Subject: [PATCH 3/3] #146: added tests for authenticating invalid credentials and sign in auth states --- src/test/kotlin/login/LoginPresenterTest.kt | 62 ++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/login/LoginPresenterTest.kt b/src/test/kotlin/login/LoginPresenterTest.kt index ed0bab3..e1f04b5 100644 --- a/src/test/kotlin/login/LoginPresenterTest.kt +++ b/src/test/kotlin/login/LoginPresenterTest.kt @@ -13,11 +13,14 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import login.LoginEvent.Login import login.LoginPresenter.Companion.AUTHENTICATING +import login.LoginPresenter.Companion.LOGIN_FAILURE +import login.LoginPresenter.Companion.LOGIN_SUCCESS import login.LoginUseCase.LoginResult import login.LoginViewState.Authenticating import login.LoginViewState.Idle import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -58,7 +61,6 @@ class LoginPresenterTest { @Test fun `when login, then immediately show init status`() = runTest(dispatcher) { -// coEvery { logIn(any()) } coAnswersDelayed { Idle() } every { auth.state() } returns flowOf(AuthState.InitState) @@ -100,6 +102,64 @@ class LoginPresenterTest { } } + @Test + fun `when auth status is authenticating, then show user authenticating`() = + runTest(dispatcher) { + every { + auth.state() + } returns flowOf(AuthState.Authenticating) + presenter.attach(view) + advanceUntilIdle() + verifyOrder { + view.show( + withArg { + assertTrue(it is Idle) + assertEquals(it.status, AUTHENTICATING) + }, + ) + } + } + + @Test + fun `when auth status is invalid credentials, then show login failure`() = + runTest(dispatcher) { + every { + auth.state() + } returns flowOf(AuthState.InvalidCredentials) + + presenter.attach(view) + + advanceUntilIdle() + verifyOrder { + view.show( + withArg { + assertTrue(it is Idle) + assertEquals(it.status, LOGIN_FAILURE) + }, + ) + } + } + + @Test + fun `when auth status is signed in, then show login success`() = + runTest(dispatcher) { + every { + auth.state() + } returns flowOf(AuthState.SignIn("")) + + presenter.attach(view) + + advanceUntilIdle() + verifyOrder { + view.show( + withArg { + assertTrue(it is Idle) + assertEquals(it.status, LOGIN_SUCCESS) + }, + ) + } + } + companion object { private val dispatcher = StandardTestDispatcher() }