diff --git a/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/AuthModule.kt b/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/AuthModule.kt index 3c33446..317e956 100644 --- a/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/AuthModule.kt +++ b/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/AuthModule.kt @@ -1,37 +1,22 @@ package nl.jovmit.androiddevs.domain.auth -import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import nl.jovmit.androiddevs.domain.auth.data.AuthResult -import nl.jovmit.androiddevs.domain.auth.data.User -import javax.inject.Inject import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -abstract class AuthModule { +object AuthModule { - @Binds - @Singleton - internal abstract fun bindAuthRepository( - repository: DummyAuthRepo - ): AuthRepository + @Provides + @Singleton + internal fun bindAuthRepository(): AuthRepository = + InMemoryAuthRepository() - @Binds - @Singleton - internal abstract fun bindUserSession( - userSession: InMemoryUserSession - ): UserSession - - class DummyAuthRepo @Inject constructor() : AuthRepository { - override suspend fun login(email: String, password: String): AuthResult { - return AuthResult.Success("token", User("userId", email, "about")) - } - - override suspend fun signUp(email: String, password: String, about: String): AuthResult { - return AuthResult.Success("token", User("userId", email, about)) - } - } + @Provides + @Singleton + internal fun bindUserSession(): UserSession = + InMemoryUserSession() } diff --git a/testutils/src/main/java/nl/jovmit/androiddevs/domain/auth/InMemoryAuthRepository.kt b/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/InMemoryAuthRepository.kt similarity index 95% rename from testutils/src/main/java/nl/jovmit/androiddevs/domain/auth/InMemoryAuthRepository.kt rename to domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/InMemoryAuthRepository.kt index fc02e6d..b3d8923 100644 --- a/testutils/src/main/java/nl/jovmit/androiddevs/domain/auth/InMemoryAuthRepository.kt +++ b/domain/auth/src/main/java/nl/jovmit/androiddevs/domain/auth/InMemoryAuthRepository.kt @@ -17,6 +17,7 @@ class InMemoryAuthRepository( override suspend fun login(email: String, password: String): AuthResult { if (isUnavailable) return AuthResult.BackendError if (isOffline) return AuthResult.OfflineError + val matchingUsers = _usersForPassword.getOrElse(password) { emptyList() } val found = matchingUsers.find { it.email == email } found?.let { user -> @@ -33,6 +34,7 @@ class InMemoryAuthRepository( if (isUnavailable) return AuthResult.BackendError if (isOffline) return AuthResult.OfflineError if (isKnownUser(email)) return AuthResult.ExistingUserError + val user = User(UUID.randomUUID().toString(), email, about) saveUserData(password, user) return AuthResult.Success(authToken, user) @@ -44,10 +46,9 @@ class InMemoryAuthRepository( private fun saveUserData(password: String, user: User) { val currentUsers = _usersForPassword.getOrElse(password) { emptyList() } - currentUsers.toMutableList().apply { + _usersForPassword[password] = currentUsers.toMutableList().apply { add(user) } - _usersForPassword[password] = currentUsers } fun setLoggedInUsers(usersForPassword: Map>) { @@ -61,4 +62,4 @@ class InMemoryAuthRepository( fun setOffline() { isOffline = true } -} \ No newline at end of file +} diff --git a/domain/auth/src/test/java/nl/jovmit/androiddevs/base/auth/InMemoryAuthTest.kt b/domain/auth/src/test/java/nl/jovmit/androiddevs/base/auth/InMemoryAuthTest.kt index e1085c2..e2cca67 100644 --- a/domain/auth/src/test/java/nl/jovmit/androiddevs/base/auth/InMemoryAuthTest.kt +++ b/domain/auth/src/test/java/nl/jovmit/androiddevs/base/auth/InMemoryAuthTest.kt @@ -1,11 +1,36 @@ package nl.jovmit.androiddevs.base.auth +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest import nl.jovmit.androiddevs.domain.auth.AuthRepository import nl.jovmit.androiddevs.domain.auth.InMemoryAuthRepository +import nl.jovmit.androiddevs.domain.auth.data.AuthResult import nl.jovmit.androiddevs.domain.auth.data.User +import org.junit.jupiter.api.Test class InMemoryAuthTest : AuthContractTest() { + @Test + fun signedUpUsersHaveDistinctIdentities() = runTest { + val repository = InMemoryAuthRepository() + + val alice = repository.signUp("alice@androiddevs.nl", "passWord12.", "Compose mentor") + val bob = repository.signUp("bob@androiddevs.nl", "passWord12.", "Architecture coach") + + assertThat((alice as AuthResult.Success).user.userId) + .isNotEqualTo((bob as AuthResult.Success).user.userId) + } + + @Test + fun signedUpUsersCanLogInWithTheirCredentials() = runTest { + val repository = InMemoryAuthRepository() + val signedUp = repository.signUp("alice@androiddevs.nl", "passWord12.", "Compose mentor") + + val loggedIn = repository.login("alice@androiddevs.nl", "passWord12.") + + assertThat(loggedIn).isEqualTo(signedUp) + } + override fun authRepositoryWith( authToken: String, usersForPassword: Map> @@ -20,4 +45,4 @@ class InMemoryAuthTest : AuthContractTest() { override fun offlineAuthRepository(): AuthRepository { return InMemoryAuthRepository().apply { setOffline() } } -} \ No newline at end of file +} diff --git a/feature/postdetails/build.gradle.kts b/feature/postdetails/build.gradle.kts index d96f0e6..1747690 100644 --- a/feature/postdetails/build.gradle.kts +++ b/feature/postdetails/build.gradle.kts @@ -65,6 +65,7 @@ dependencies { kapt(libs.hilt.compiler) testImplementation(libs.bundles.unit.testing) + testImplementation(projects.testutils) testRuntimeOnly(libs.junit.jupiter.engine) } diff --git a/feature/postdetails/src/main/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsViewModel.kt b/feature/postdetails/src/main/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsViewModel.kt index 3f370fe..d9baf62 100644 --- a/feature/postdetails/src/main/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsViewModel.kt +++ b/feature/postdetails/src/main/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsViewModel.kt @@ -6,10 +6,13 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import nl.jovmit.androiddevs.domain.auth.UserSession +import nl.jovmit.androiddevs.domain.auth.data.User import nl.jovmit.androiddevs.domain.timeline.PostDetailsResult import nl.jovmit.androiddevs.domain.timeline.RemovePostResult import nl.jovmit.androiddevs.domain.timeline.TimelineRepository @@ -27,6 +30,12 @@ class PostDetailsViewModel @Inject constructor( val screenState = _screenState.asStateFlow() + init { + userSession.sessionUser + .onEach(::onSessionUserChanged) + .launchIn(viewModelScope) + } + fun loadPostDetails(postId: String) { viewModelScope.launch { setLoading() @@ -59,7 +68,7 @@ class PostDetailsViewModel @Inject constructor( _screenState.update { when (result) { is PostDetailsResult.Success -> PostDetailsScreenState( - postItem = result.post.toPostDetailsItem() + postItem = result.post.toPostDetailsItem(userSession.sessionUser.value) ) PostDetailsResult.PostNotFound -> PostDetailsScreenState(isNotFound = true) PostDetailsResult.Offline -> PostDetailsScreenState(isOffline = true) @@ -68,10 +77,17 @@ class PostDetailsViewModel @Inject constructor( } } - private fun Post.toPostDetailsItem(): PostDetailsItem { + private fun onSessionUserChanged(sessionUser: User?) { + _screenState.update { screenState -> + val post = screenState.postItem?.post ?: return@update screenState + screenState.copy(postItem = post.toPostDetailsItem(sessionUser)) + } + } + + private fun Post.toPostDetailsItem(sessionUser: User?): PostDetailsItem { return PostDetailsItem( post = this, - canRemove = userSession.sessionUser.value?.userId == author.userId + canRemove = sessionUser?.userId == author.userId ) } } diff --git a/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/AccountSwitchPostOwnershipRegressionTest.kt b/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/AccountSwitchPostOwnershipRegressionTest.kt new file mode 100644 index 0000000..b4f3686 --- /dev/null +++ b/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/AccountSwitchPostOwnershipRegressionTest.kt @@ -0,0 +1,57 @@ +package nl.jovmit.androiddevs.feature.postdetails + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import nl.jovmit.androiddevs.domain.auth.InMemoryAuthRepository +import nl.jovmit.androiddevs.domain.auth.InMemoryUserSession +import nl.jovmit.androiddevs.domain.auth.data.AuthResult +import nl.jovmit.androiddevs.domain.timeline.AddPostResult +import nl.jovmit.androiddevs.domain.timeline.InMemoryTimelineRepository +import nl.jovmit.androiddevs.domain.timeline.RemovePostResult +import nl.jovmit.androiddevs.testutils.CoroutineTestExtension +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class AccountSwitchPostOwnershipRegressionTest { + + @Test + fun userCannotRemoveAnotherUsersPostAfterAccountSwitch() = runTest { + val authRepository = InMemoryAuthRepository() + val session = InMemoryUserSession() + val timelineRepository = InMemoryTimelineRepository(session) + val alice = authRepository.signUp( + email = "alice@androiddevs.nl", + password = "passWord12.", + about = "Compose mentor" + ).authenticatedUser() + session.setSessionUser(alice) + val alicePost = (timelineRepository.addPost( + title = "How I debug recomposition spikes", + body = "I stopped guessing and started measuring recomposition from the user-facing screen." + ) as AddPostResult.Success).post + + session.clear() + authRepository.signUp( + email = "bob@androiddevs.nl", + password = "passWord12.", + about = "Architecture coach" + ) + val bob = authRepository.login("bob@androiddevs.nl", "passWord12.").authenticatedUser() + session.setSessionUser(bob) + val viewModel = PostDetailsViewModel( + timelineRepository = timelineRepository, + userSession = session, + backgroundDispatcher = Dispatchers.Unconfined + ) + + viewModel.loadPostDetails(alicePost.id) + + assertThat(viewModel.screenState.value.postItem?.post?.id).isEqualTo(alicePost.id) + assertThat(viewModel.screenState.value.postItem?.canRemove).isFalse() + assertThat(timelineRepository.removePost(alicePost.id)).isEqualTo(RemovePostResult.NotAuthor) + } + + private fun AuthResult.authenticatedUser() = (this as AuthResult.Success).user +} diff --git a/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/ExampleUnitTest.kt b/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/ExampleUnitTest.kt deleted file mode 100644 index 9c17d7a..0000000 --- a/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/ExampleUnitTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package nl.jovmit.androiddevs.feature.postdetails - -import com.google.common.truth.Truth.assertThat -import org.junit.jupiter.api.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - - @Test - fun addition_isCorrect() { - val result = 2 + 2 - assertThat(result).isEqualTo(4) - } -} \ No newline at end of file diff --git a/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsViewModelTest.kt b/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsViewModelTest.kt new file mode 100644 index 0000000..49cc4c3 --- /dev/null +++ b/feature/postdetails/src/test/java/nl/jovmit/androiddevs/feature/postdetails/PostDetailsViewModelTest.kt @@ -0,0 +1,89 @@ +package nl.jovmit.androiddevs.feature.postdetails + +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import nl.jovmit.androiddevs.domain.auth.InMemoryUserSession +import nl.jovmit.androiddevs.domain.auth.data.User +import nl.jovmit.androiddevs.domain.timeline.AddPostResult +import nl.jovmit.androiddevs.domain.timeline.PostDetailsResult +import nl.jovmit.androiddevs.domain.timeline.RemovePostResult +import nl.jovmit.androiddevs.domain.timeline.TimelineRepository +import nl.jovmit.androiddevs.domain.timeline.TimelineResult +import nl.jovmit.androiddevs.domain.timeline.data.Post +import nl.jovmit.androiddevs.testutils.CoroutineTestExtension +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(CoroutineTestExtension::class) +class PostDetailsViewModelTest { + + private val maya = User("maya", "maya@androiddevs.nl", "Compose mentor") + private val sam = User("sam", "sam@androiddevs.nl", "Architecture coach") + private val post = Post( + id = "post-compose", + author = maya, + title = "How I debug recomposition spikes", + body = "I stopped guessing and started measuring recomposition from the user-facing screen.", + createdAtMillis = 1_700_000_000_000L + ) + private val session = InMemoryUserSession() + private val timelineRepository = FakeTimelineRepository(post) + + @Test + fun deleteVisibilityFollowsActiveSessionUser() = runTest { + session.setSessionUser(maya) + val viewModel = PostDetailsViewModel( + timelineRepository = timelineRepository, + userSession = session, + backgroundDispatcher = Dispatchers.Unconfined + ) + + viewModel.loadPostDetails(post.id) + + assertThat(viewModel.screenState.value.postItem?.canRemove).isTrue() + + session.setSessionUser(sam) + + assertThat(viewModel.screenState.value.postItem?.canRemove).isFalse() + } + + @Test + fun deleteHiddenWhenActiveSessionUserIsNotTheAuthor() = runTest { + session.setSessionUser(sam) + val viewModel = PostDetailsViewModel( + timelineRepository = timelineRepository, + userSession = session, + backgroundDispatcher = Dispatchers.Unconfined + ) + + viewModel.loadPostDetails(post.id) + + assertThat(viewModel.screenState.value.postItem?.canRemove).isFalse() + } + + private class FakeTimelineRepository( + private val post: Post + ) : TimelineRepository { + + override val timeline: Flow = MutableStateFlow(TimelineResult.Success(emptyList())) + + override suspend fun getPost(postId: String): PostDetailsResult { + return if (postId == post.id) { + PostDetailsResult.Success(post) + } else { + PostDetailsResult.PostNotFound + } + } + + override suspend fun addPost(title: String, body: String): AddPostResult { + error("Not needed for this test") + } + + override suspend fun removePost(postId: String): RemovePostResult { + error("Not needed for this test") + } + } +}