diff --git a/app/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthService.kt b/app/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthService.kt index 07e68aa8..15013902 100644 --- a/app/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthService.kt +++ b/app/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthService.kt @@ -11,6 +11,7 @@ import com.omricat.maplibrarian.auth.AuthError import com.omricat.maplibrarian.auth.AuthService import com.omricat.maplibrarian.auth.Credential import com.omricat.maplibrarian.auth.EmailPasswordCredential +import com.omricat.maplibrarian.model.EmailAddress import com.omricat.maplibrarian.model.User import com.omricat.maplibrarian.model.UserUid import com.omricat.maplibrarian.utils.DispatcherProvider @@ -82,4 +83,15 @@ internal value class FirebaseUser(private val user: com.google.firebase.auth.Fir override val id: UserUid get() = UserUid(user.uid) + override val emailAddress: EmailAddress + get() { + val email = + user.email + ?: error( + """FirebaseUser email should always be non-null unless Multiple + | accounts per email has been enable in Firebase Console""" + .trimMargin() + ) + return EmailAddress(email) + } } diff --git a/core/src/main/kotlin/com/omricat/maplibrarian/model/User.kt b/core/src/main/kotlin/com/omricat/maplibrarian/model/User.kt index 14ad85ed..957396fb 100644 --- a/core/src/main/kotlin/com/omricat/maplibrarian/model/User.kt +++ b/core/src/main/kotlin/com/omricat/maplibrarian/model/User.kt @@ -4,9 +4,12 @@ import kotlinx.serialization.Serializable @Serializable @JvmInline public value class UserUid(public val value: String) +@Serializable @JvmInline public value class EmailAddress(public val value: String) + public interface User { public val displayName: String public val id: UserUid + public val emailAddress: EmailAddress public companion object } diff --git a/core/src/test/kotlin/com/omricat/maplibrarian/auth/ActualAuthWorkflowTest.kt b/core/src/test/kotlin/com/omricat/maplibrarian/auth/ActualAuthWorkflowTest.kt index 4060cf36..e930f6ad 100644 --- a/core/src/test/kotlin/com/omricat/maplibrarian/auth/ActualAuthWorkflowTest.kt +++ b/core/src/test/kotlin/com/omricat/maplibrarian/auth/ActualAuthWorkflowTest.kt @@ -47,7 +47,7 @@ public class ActualAuthWorkflowTest : "onAuthenticated action outputs Authenticated(user) from workflow" { val workflow = ActualAuthWorkflow(TestAuthService(), NullSignupWorkflow) val fakeCredential = EmailPasswordCredential("a@b.com", "12345") - val fakeUser = TestUser("user1", UserUid("1")) + val fakeUser = TestUser("user1", UserUid("1"), "blah@example.com") val (_, maybeOutput) = workflow .onAuthenticated(fakeUser) diff --git a/core/src/test/kotlin/com/omricat/maplibrarian/auth/ActualSignUpWorkflowTest.kt b/core/src/test/kotlin/com/omricat/maplibrarian/auth/ActualSignUpWorkflowTest.kt index a054235a..23b72e83 100644 --- a/core/src/test/kotlin/com/omricat/maplibrarian/auth/ActualSignUpWorkflowTest.kt +++ b/core/src/test/kotlin/com/omricat/maplibrarian/auth/ActualSignUpWorkflowTest.kt @@ -50,7 +50,7 @@ internal class ActualSignUpWorkflowTest : "onUserCreated sets output to created user" { val workflow = ActualSignUpWorkflow(TestAuthService()) val credential = EmailPasswordCredential("blah@blah", "password") - val user = TestUser("user1", UserUid("1234")) + val user = TestUser("user1", UserUid("1234"), "blah@example.com") val (_, maybeOutput) = workflow diff --git a/core/src/test/kotlin/com/omricat/maplibrarian/auth/TestUser.kt b/core/src/test/kotlin/com/omricat/maplibrarian/auth/TestUser.kt index eab7bc73..9ba2976f 100644 --- a/core/src/test/kotlin/com/omricat/maplibrarian/auth/TestUser.kt +++ b/core/src/test/kotlin/com/omricat/maplibrarian/auth/TestUser.kt @@ -1,6 +1,16 @@ package com.omricat.maplibrarian.auth +import com.omricat.maplibrarian.model.EmailAddress import com.omricat.maplibrarian.model.User import com.omricat.maplibrarian.model.UserUid -internal data class TestUser(override val displayName: String, override val id: UserUid) : User +internal data class TestUser( + override val displayName: String, + override val id: UserUid, + override val emailAddress: EmailAddress +) : User { + companion object { + operator fun invoke(displayName: String, id: UserUid, emailAddress: String): TestUser = + TestUser(displayName, id, EmailAddress(emailAddress)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ea289273..5c46756e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,5 @@ [versions] +assertk = "0.26.1" beagle = "2.9.0" detekt = "1.18.1" firebase = "29.0.0" @@ -57,6 +58,7 @@ firebase_firestoreKtx = { group = "com.google.firebase", name = "firebase-firest firebase_authKtx = { group = "com.google.firebase", name = "firebase-auth-ktx" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit2" } +retrofit_converter_kotlinXSerialization = { module = "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter", version = "1.0.0" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp3" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } @@ -64,6 +66,8 @@ material = { group = "com.google.android.material", name = "material", version.r jakeWharton_processPhoenix = { module = "com.jakewharton:process-phoenix", version.ref = "processPhoenix" } jakeWharton_timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } +assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } + # Used in build-logic android_gradlePlugin_api = { module = "com.android.tools.build:gradle-api", version.ref = "agp" } android_gradlePlugin = { module = "com.android.tools.build:gradle", version.ref = "agp" } diff --git a/integration-tests/firebase/build.gradle.kts b/integration-tests/firebase/build.gradle.kts index 9e887900..f5485776 100644 --- a/integration-tests/firebase/build.gradle.kts +++ b/integration-tests/firebase/build.gradle.kts @@ -1,3 +1,5 @@ +@file:Suppress("UnstableApiUsage") + import com.android.build.api.dsl.ManagedVirtualDevice import okhttp3.HttpUrl import okhttp3.OkHttpClient @@ -12,7 +14,10 @@ buildscript { dependencies { classpath(libs.okhttp) } } -plugins { alias(libs.plugins.maplib.android.test) } +plugins { + alias(libs.plugins.maplib.android.test) + alias(libs.plugins.kotlin.serialization) +} android { namespace = "com.omricat.maplibrarian.integrationtesting.debug" @@ -80,12 +85,17 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.test) + implementation(platform(libs.kotlinx.serialization.bom)) + implementation(libs.kotlinx.serialization.json) + implementation(platform(libs.firebase.bom)) implementation(libs.firebase.firestoreKtx) implementation(libs.firebase.authKtx) implementation(libs.retrofit) + implementation(libs.retrofit.converter.kotlinXSerialization) + implementation(projects.util.kotlinResultAssertkExtensions) implementation(libs.kotlinResult) implementation(libs.kotlinResult.coroutines) @@ -93,5 +103,6 @@ dependencies { implementation(androidx.test.runner) implementation(androidx.test.ext.junitKtx) - implementation(androidx.test.ext.truth) + + implementation(libs.assertk) } diff --git a/integration-tests/firebase/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthEmulatorRestApi.kt b/integration-tests/firebase/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthEmulatorRestApi.kt index 07e3d6a3..e5a738bf 100644 --- a/integration-tests/firebase/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthEmulatorRestApi.kt +++ b/integration-tests/firebase/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthEmulatorRestApi.kt @@ -1,21 +1,75 @@ package com.omricat.maplibrarian.firebase.auth +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.omricat.maplibrarian.auth.EmailPasswordCredential +import com.omricat.maplibrarian.model.EmailAddress +import com.omricat.maplibrarian.model.User +import com.omricat.maplibrarian.model.UserUid +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody import retrofit2.Call import retrofit2.Response import retrofit2.Retrofit import retrofit2.create +import retrofit2.http.Body import retrofit2.http.DELETE +import retrofit2.http.Headers +import retrofit2.http.POST import retrofit2.http.Path class FirebaseAuthEmulatorRestApi(private val projectId: String, baseUrl: HttpUrl) { + private val jsonMediaType = "application/json; charset=utf-8".toMediaType() + private interface AuthEmulatorApi { @DELETE("/emulator/v1/projects/{project-id}/accounts") fun deleteAllUsers(@Path("project-id") projectId: String): Call + + @Headers("Authorization: Bearer owner") + @POST("/identitytoolkit.googleapis.com/v1/projects/{project-id}/accounts") + fun createUser( + @Path("project-id") projectId: String, + @Body body: RequestBody + ): Call + } + + @Serializable + data class TestUser(override val displayName: String, val localId: String, val email: String) : + User { + override val id: UserUid = UserUid(localId) + override val emailAddress: EmailAddress = EmailAddress(email) } - private val wrappedApi: AuthEmulatorApi = Retrofit.Builder().baseUrl(baseUrl).build().create() + private val json = Json { ignoreUnknownKeys = true } + + private val wrappedApi: AuthEmulatorApi = + Retrofit.Builder() + .baseUrl(baseUrl) + .addConverterFactory(json.asConverterFactory(jsonMediaType)) + .build() + .create() fun deleteAllUsers(): Response = wrappedApi.deleteAllUsers(projectId).execute() + + fun createUser(credential: EmailPasswordCredential): Response { + + val jsonBody = + """{ + "customAttributes":"", + "displayName":"", + "photoUrl":"", + "email":"${credential.emailAddress}", + "password":"${credential.password}", + "phoneNumber":"", + "emailVerified":false, + "mfaInfo":[] + }""" + .toRequestBody(contentType = jsonMediaType) + val call = wrappedApi.createUser(projectId, jsonBody) + return call.execute() + } } diff --git a/integration-tests/firebase/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthServiceTest.kt b/integration-tests/firebase/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthServiceTest.kt index d4dde648..c83b7a01 100644 --- a/integration-tests/firebase/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthServiceTest.kt +++ b/integration-tests/firebase/src/main/kotlin/com/omricat/maplibrarian/firebase/auth/FirebaseAuthServiceTest.kt @@ -1,12 +1,15 @@ package com.omricat.maplibrarian.firebase.auth -import com.github.michaelbull.result.Ok +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.prop import com.google.firebase.auth.FirebaseAuth import com.omricat.maplibrarian.auth.EmailPasswordCredential import com.omricat.maplibrarian.firebase.FirebaseEmulatorConnection import com.omricat.maplibrarian.firebase.TestDispatcherProvider import com.omricat.maplibrarian.firebase.TestFixtures -import com.omricat.maplibrarian.model.User +import com.omricat.maplibrarian.firebase.auth.FirebaseAuthEmulatorRestApi.TestUser +import com.omricat.result.kotest.assertk.isOk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Before @@ -28,7 +31,29 @@ class FirebaseAuthServiceTest { val repository = FirebaseAuthService(firebaseAuthInstance, TestDispatcherProvider(testScheduler)) val createUserResult = repository.createUser(testCredential) - assert(createUserResult is Ok) + assertThat(createUserResult).isOk() + } + + @Test + fun canSignInAddedUser() = runTest { + val repository = + FirebaseAuthService(firebaseAuthInstance, TestDispatcherProvider(testScheduler)) + val createUserResult = repository.createUser(testCredential) + repository.signOut() + val signInResult = repository.attemptAuthentication(testCredential) + assertThat(signInResult).isOk() + } + + @Test + fun canSignInExternallyAddedUser() = runTest { + val createdUser = Fixtures.createUserViaRestApi(testCredential) + val repository = + FirebaseAuthService(firebaseAuthInstance, TestDispatcherProvider(testScheduler)) + val signInResult = repository.attemptAuthentication(testCredential) + assertThat(signInResult) + .isOk() + .prop("Email address") { it.emailAddress.value } + .isEqualTo(createdUser.email) } companion object Fixtures { @@ -53,5 +78,9 @@ class FirebaseAuthServiceTest { TestFixtures.emulatorBaseUrl(FirebaseEmulatorConnection.AUTH_PORT) ) } + + fun createUserViaRestApi(credential: EmailPasswordCredential): TestUser = + authApi.createUser(credential).body() + ?: error("Failed to create user with credentials $credential") } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3a2878cd..fe00d050 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -44,6 +44,6 @@ rootProject.name = ("map-librarian") include( ":app", ":core", - ":kotlin-result-kotest", + ":util:kotlin-result-assertk-extensions", ":integration-tests:firebase", ) diff --git a/kotlin-result-kotest/.gitignore b/util/kotlin-result-assertk-extensions/.gitignore similarity index 100% rename from kotlin-result-kotest/.gitignore rename to util/kotlin-result-assertk-extensions/.gitignore diff --git a/kotlin-result-kotest/build.gradle.kts b/util/kotlin-result-assertk-extensions/build.gradle.kts similarity index 91% rename from kotlin-result-kotest/build.gradle.kts rename to util/kotlin-result-assertk-extensions/build.gradle.kts index 1b1f34be..e54d7cda 100644 --- a/kotlin-result-kotest/build.gradle.kts +++ b/util/kotlin-result-assertk-extensions/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.maplib.kotlin.library) } dependencies { api(libs.kotlinResult) api(libs.kotest.assertions.core) + api(libs.assertk) } kotlin { sourceSets.all { languageSettings.optIn("kotlin.contracts.ExperimentalContracts") } } diff --git a/util/kotlin-result-assertk-extensions/src/main/kotlin/com/omricat/result/kotest/assertk/assertions.kt b/util/kotlin-result-assertk-extensions/src/main/kotlin/com/omricat/result/kotest/assertk/assertions.kt new file mode 100644 index 00000000..908f4887 --- /dev/null +++ b/util/kotlin-result-assertk-extensions/src/main/kotlin/com/omricat/result/kotest/assertk/assertions.kt @@ -0,0 +1,12 @@ +package com.omricat.result.kotest.assertk + +import assertk.Assert +import assertk.assertions.isInstanceOf +import com.github.michaelbull.result.Err +import com.github.michaelbull.result.Ok +import com.github.michaelbull.result.Result + +public fun Assert>.isOk(): Assert = isInstanceOf>().transform { it.value } + +public fun Assert>.isErr(): Assert = + isInstanceOf>().transform { it.error } diff --git a/kotlin-result-kotest/src/main/kotlin/com/omricat/result/kotest/matchers.kt b/util/kotlin-result-assertk-extensions/src/main/kotlin/com/omricat/result/kotest/matchers.kt similarity index 100% rename from kotlin-result-kotest/src/main/kotlin/com/omricat/result/kotest/matchers.kt rename to util/kotlin-result-assertk-extensions/src/main/kotlin/com/omricat/result/kotest/matchers.kt