diff --git a/README.md b/README.md index 15458b0..a2c17e6 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,17 @@ one call (`sendEmailVerification()` after sign-up, then gating the feed on project are test data, and a mandatory confirmation step would put a mailbox between a reviewer and the running app for no benefit at this scale. +**Registration writes to two systems, and only one of them can be rolled back.** An auth +account is created first, then a Firestore profile. If the profile write fails the account +is deleted again — but that deletion can fail too, and then the account exists with no +profile: the person is registered as far as Firebase is concerned, cannot sign up again +with that address, and cannot use the app, because every screen needs the profile. There +is no transaction spanning both systems on this plan, so the second line of defence is a +recovery screen: a session with no profile is routed to +[`CompleteProfileActivity`](app/src/main/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileActivity.kt), +which writes the missing document from the session's own uid and address. One such +account existed in the live project and was recovered through it. + **Deleting a note reports failure with a `Toast`.** Unlike a failed read, the user can repeat a failed delete by tapping the button again, so the toast does not leave them stuck — but the retry is still theirs to figure out. Offering it in the snackbar would @@ -440,7 +451,7 @@ emulator on API 24+. ./gradlew testDebugUnitTest ``` -126 JVM tests, no emulator and no network. They cover the layers where a bug would be +139 JVM tests, no emulator and no network. They cover the layers where a bug would be invisible rather than loud: the scoring arithmetic, the decision logic in every ViewModel, the list diffing that decides which rows get redrawn, and — for the feature the app is built around — what the screen actually shows. @@ -453,11 +464,13 @@ the app is built around — what the screen actually shows. | `NoteDetailViewModelTest` | Metadata and file content loading as separate states, no file read for a note without one, and the rating rules (own note, missing session, deleted note). | | `EditNoteViewModelTest` | Ownership and session handling on load and save. | | `ProfileViewModelTest` | Total points summed from the user's own notes, and note deletion. | -| `LoginViewModelTest`, `RegisterViewModelTest` | State transitions, and which of registration's two steps — auth account or profile document — failed. | +| `LoginViewModelTest`, `RegisterViewModelTest` | State transitions, which of registration's two steps — auth account or profile document — failed, and that a failed profile write rolls the auth account back. Including the case where the rollback itself fails: the user still sees the profile error, because from where they stand the result is the same. | +| `CompleteProfileViewModelTest` | The recovery path for an account that has no profile: the write takes its uid and address from the session rather than the request, a missing session writes nothing, a double tap saves one profile, and a failed write leaves the screen usable. | +| `MainViewModelTest` | Session routing, including the three-way decision on startup: signed out, signed in with a profile, and signed in without one. Also that a profile which cannot be *read* is not treated as one that does not exist — a dropped connection must not push someone into a screen where they cannot write either. | | `AuthErrorMessagesTest` | That each Firebase auth error code reaches its own Turkish message rather than the catch-all. The mapping used to match on Firebase's English error *sentence*; those strings are not API, they changed, and every failure quietly collapsed into "Tekrar deneyin." with no test to notice. `ERROR_INVALID_CREDENTIAL` is the sharp case — with email enumeration protection on, it is what both a wrong password and an unknown account now return. | | `PasswordToggleTest` | That the password field starts masked and its toggle icon reports state, not action: unchecked (crossed-out eye) while hidden, checked (open eye) while visible. The icon is a custom selector wired through `endIconDrawable`, so dropping that one attribute leaves a working button with the inverted icon — invisible to every other test. | | `LeaderboardViewModelTest` | Empty vs. content, and the department scoping: which department the ranking is asked for, and the three cases — no session, no profile, blank department — where the query must not be built at all. | -| `MainViewModelTest`, `UploaderNameTest` | Session routing, and the uploader-name masking shared by three screens. | +| `UploaderNameTest` | The uploader-name derivation, which the rule mirrors with `email.split('@')[0]`. | | `PostAdapterTest`, `LeaderboardAdapterTest` | Which rows a `DiffUtil` pass marks as changed. The leaderboard case is the sharp one: a note's medal depends on its rank, so a note that swapped places without changing must still be rebound, or the gold medal stays on the row it left. | | `FeedScreenTest` | The contribution gate as a user meets it — the lock and its way out, the unlocked feed and its notes. See [Screen tests](#screen-tests). | diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f4e58e8..6c87660 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,6 +36,9 @@ + by lazy { + listOf(getString(R.string.department_prompt)) + + resources.getStringArray(R.array.departments) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityCompleteProfileBinding.inflate(layoutInflater) + setContentView(binding.root) + + binding.tvEmail.text = viewModel.currentEmail() + setupSpinner() + observeState() + + binding.btnSave.setOnClickListener { + val department = selectedDepartment() + if (department == null) { + Toast.makeText(this, R.string.error_department_not_selected, Toast.LENGTH_SHORT) + .show() + return@setOnClickListener + } + viewModel.completeProfile(department) + } + + binding.btnSignOut.setOnClickListener { + viewModel.signOut() + goToLogin() + } + } + + private fun setupSpinner() { + val adapter = ArrayAdapter( + this, + android.R.layout.simple_spinner_item, + departments + ) + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + binding.spDepartment.adapter = adapter + } + + private fun selectedDepartment(): String? = + binding.spDepartment.selectedItemPosition + .takeIf { it != DEPARTMENT_PROMPT_INDEX } + ?.let { departments[it] } + + private fun observeState() { + viewModel.uiState.observe(this) { state -> + when (state) { + CompleteProfileUiState.Idle -> showLoading(false) + CompleteProfileUiState.Loading -> showLoading(true) + + CompleteProfileUiState.Success -> { + showLoading(false) + Toast.makeText(this, R.string.complete_profile_saved, Toast.LENGTH_SHORT) + .show() + startActivity( + Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + } + + // Oturum bu ekran açıkken kaybolduysa yazacak bir uid yok; girişe dön. + CompleteProfileUiState.MissingSession -> { + showLoading(false) + Toast.makeText(this, R.string.error_missing_session, Toast.LENGTH_SHORT).show() + goToLogin() + } + + is CompleteProfileUiState.Error -> { + showLoading(false) + Toast.makeText( + this, + getString( + R.string.register_error_profile_save, + state.exception.message.orEmpty() + ), + Toast.LENGTH_LONG + ).show() + } + } + } + } + + private fun showLoading(show: Boolean) { + binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE + binding.btnSave.isEnabled = !show + binding.btnSignOut.isEnabled = !show + } + + private fun goToLogin() { + startActivity( + Intent(this, LoginActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + ) + } + + private companion object { + const val DEPARTMENT_PROMPT_INDEX = 0 + } +} diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileViewModel.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileViewModel.kt new file mode 100644 index 0000000..bff4098 --- /dev/null +++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileViewModel.kt @@ -0,0 +1,88 @@ +package duygu.yilmaz.campusnote.ui.auth + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import duygu.yilmaz.campusnote.data.model.UserProfile +import duygu.yilmaz.campusnote.data.repository.AuthRepository +import duygu.yilmaz.campusnote.data.repository.FirebaseAuthRepository +import duygu.yilmaz.campusnote.data.repository.FirebaseUserRepository +import duygu.yilmaz.campusnote.data.repository.UserRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch + +/** + * Auth hesabı olup Firestore profili olmayan oturumu toparlar. + * + * Kayıt iki ayrı sisteme yazıyor. [RegisterViewModel] profil yazılamadığında Auth + * hesabını geri alıyor, ama o telafi de düşebilir — ve düştüğünde geriye kimsenin + * çıkamadığı bir hesap kalıyor: kişi Firebase'de var, aynı adresle yeniden kayıt + * olamıyor, profili olmadığı için feed kilitli ve yükleme çalışmıyor. + * + * Buradaki ekran o hesabın tek çıkışı. Yeni bir hesap açmıyor; var olan oturumun + * uid'si ve e-postasıyla eksik profili yazıyor. E-posta oturumdan alınıyor çünkü + * güvenlik kuralı zaten `request.auth.token.email` ile karşılaştırıyor — kullanıcıya + * sorulacak tek şey bölüm. + */ +class CompleteProfileViewModel( + private val authRepository: AuthRepository = FirebaseAuthRepository(), + private val userRepository: UserRepository = FirebaseUserRepository() +) : ViewModel() { + + private val _uiState = MutableLiveData(CompleteProfileUiState.Idle) + val uiState: LiveData = _uiState + + /** Oturumun e-postası; ekranda "hangi hesabı tamamlıyorsun" olarak gösteriliyor. */ + fun currentEmail(): String = authRepository.currentUser()?.email.orEmpty() + + fun completeProfile(department: String) { + if (_uiState.value == CompleteProfileUiState.Loading) return + + val user = authRepository.currentUser() + if (user == null) { + _uiState.value = CompleteProfileUiState.MissingSession + return + } + + _uiState.value = CompleteProfileUiState.Loading + viewModelScope.launch { + _uiState.value = try { + userRepository.saveUser( + UserProfile( + id = user.uid, + email = user.email, + department = department + ) + ) + CompleteProfileUiState.Success + } catch (cancellation: CancellationException) { + throw cancellation + } catch (exception: Exception) { + CompleteProfileUiState.Error(exception) + } + } + } + + /** + * Tamamlamadan vazgeçen kullanıcıyı oturumdan çıkarır. + * + * Profilsiz bir oturumla uygulamada dolaşmanın anlamı yok — feed kilitli, yükleme + * bölüm istiyor, profil ekranı boş. Çıkış, giriş ekranına dönüp başka bir hesapla + * devam etmenin yolu. + */ + fun signOut() { + authRepository.signOut() + } +} + +sealed interface CompleteProfileUiState { + data object Idle : CompleteProfileUiState + data object Loading : CompleteProfileUiState + data object Success : CompleteProfileUiState + + /** Oturum bu ekran açıkken kaybolduysa: yazacak bir uid yok. */ + data object MissingSession : CompleteProfileUiState + + data class Error(val exception: Exception) : CompleteProfileUiState +} diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterViewModel.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterViewModel.kt index 3662ba4..4292784 100644 --- a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterViewModel.kt +++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterViewModel.kt @@ -49,6 +49,7 @@ class RegisterViewModel( } catch (cancellation: CancellationException) { throw cancellation } catch (exception: Exception) { + rollBackAccount() AuthUiState.Error( exception = exception, stage = AuthFailureStage.USER_PROFILE @@ -57,6 +58,29 @@ class RegisterViewModel( } } + /** + * Profil yazılamadığında az önce açılan Auth hesabını geri alır. + * + * Kayıt iki sisteme yazıyor ve arada yarıda kalabiliyor. Geri almazsak kişi + * Firebase'de var olur, aynı adresle yeniden kayıt olamaz ve profili olmadığı + * için uygulamayı da kullanamaz — kimsenin çıkamadığı bir durum. + * + * Silmenin kendisi de düşebilir; o zaman kullanıcıya gösterilecek mesaj + * değişmiyor, çünkü kullanıcı açısından sonuç aynı: kayıt olmadı. Fark, hesabın + * ortada kalmasında ve onu [duygu.yilmaz.campusnote.ui.profile.CompleteProfileActivity] + * topluyor. Bu yüzden burada hata yutuluyor — telafi başarısız olduğu için + * asıl hatanın üstünü örtmenin anlamı yok. + */ + private suspend fun rollBackAccount() { + try { + authRepository.deleteCurrentUser() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + // Bilinçli olarak yutuluyor; yukarıdaki açıklamaya bakın. + } + } + fun resetState() { _uiState.value = AuthUiState.Idle } diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/main/MainActivity.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/main/MainActivity.kt index 79d9669..9a922f9 100644 --- a/app/src/main/java/duygu/yilmaz/campusnote/ui/main/MainActivity.kt +++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/main/MainActivity.kt @@ -6,6 +6,7 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import duygu.yilmaz.campusnote.R import duygu.yilmaz.campusnote.databinding.ActivityMainBinding +import duygu.yilmaz.campusnote.ui.auth.CompleteProfileActivity import duygu.yilmaz.campusnote.ui.auth.LoginActivity import duygu.yilmaz.campusnote.ui.feed.FeedFragment import duygu.yilmaz.campusnote.ui.leaderboard.LeaderboardFragment @@ -26,6 +27,15 @@ class MainActivity : AppCompatActivity() { return } + // Oturum var ama profili olmayabilir; bkz. MainViewModel.resolveRoute. + viewModel.route.observe(this) { route -> + if (route == MainRoute.NeedsProfile) { + startActivity(Intent(this, CompleteProfileActivity::class.java)) + finish() + } + } + viewModel.resolveRoute() + binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/main/MainViewModel.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/main/MainViewModel.kt index 5bd107a..d70e163 100644 --- a/app/src/main/java/duygu/yilmaz/campusnote/ui/main/MainViewModel.kt +++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/main/MainViewModel.kt @@ -1,11 +1,65 @@ package duygu.yilmaz.campusnote.ui.main +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import duygu.yilmaz.campusnote.data.repository.AuthRepository import duygu.yilmaz.campusnote.data.repository.FirebaseAuthRepository +import duygu.yilmaz.campusnote.data.repository.FirebaseUserRepository +import duygu.yilmaz.campusnote.data.repository.UserRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch class MainViewModel( - private val authRepository: AuthRepository = FirebaseAuthRepository() + private val authRepository: AuthRepository = FirebaseAuthRepository(), + private val userRepository: UserRepository = FirebaseUserRepository() ) : ViewModel() { + + private val _route = MutableLiveData() + val route: LiveData = _route + fun hasAuthenticatedUser(): Boolean = authRepository.currentUser() != null + + /** + * Oturumun kullanılabilir olup olmadığına karar verir. + * + * Auth hesabı olup Firestore profili olmayan bir oturum mümkün: kayıt iki ayrı + * sisteme yazıyor ve ikincisi düşerse — üstelik geri alma da düşerse — geriye + * profilsiz bir hesap kalıyor. O hesapla uygulamada dolaşmak bir çıkmaz: feed + * kilitli, yükleme bölüm istiyor, profil ekranı boş. Böyle bir oturum profil + * tamamlama ekranına gidiyor. + * + * Profilin *okunamaması* ile *olmaması* ayrı tutuluyor. Ağ düşmesi yüzünden + * okunamayan profil kullanıcıyı tamamlama ekranına atmamalı — orada zaten + * yazamaz. Bu durumda uygulama normal açılıyor; ekranlar profil hatasını kendi + * yeniden deneme mesajlarıyla gösteriyor. + */ + fun resolveRoute() { + val user = authRepository.currentUser() + if (user == null) { + _route.value = MainRoute.SignedOut + return + } + + viewModelScope.launch { + _route.value = try { + if (userRepository.getUser(user.uid) == null) { + MainRoute.NeedsProfile + } else { + MainRoute.Ready + } + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + MainRoute.Ready + } + } + } +} + +sealed interface MainRoute { + data object SignedOut : MainRoute + data object NeedsProfile : MainRoute + data object Ready : MainRoute } diff --git a/app/src/main/res/layout/activity_complete_profile.xml b/app/src/main/res/layout/activity_complete_profile.xml new file mode 100644 index 0000000..ef0d8bb --- /dev/null +++ b/app/src/main/res/layout/activity_complete_profile.xml @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a5d1c9..fd74e25 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -62,6 +62,11 @@ Email veya şifre hatalı Giriş başarısız. Tekrar deneyin. + + Hesabın oluşturulmuş ama profilin tamamlanmamış. Bölümünü seçince devam edebilirsin. + Profili tamamla + Profilin tamamlandı! + Yeni hesap oluştur Kayıt ol diff --git a/app/src/test/java/duygu/yilmaz/campusnote/testing/FakeRepositories.kt b/app/src/test/java/duygu/yilmaz/campusnote/testing/FakeRepositories.kt index 95d35ef..0145bdd 100644 --- a/app/src/test/java/duygu/yilmaz/campusnote/testing/FakeRepositories.kt +++ b/app/src/test/java/duygu/yilmaz/campusnote/testing/FakeRepositories.kt @@ -31,6 +31,7 @@ class FakeAuthRepository( var signInError: Exception? = null var registerError: Exception? = null + var deleteError: Exception? = null var signInCount = 0 private set @@ -38,6 +39,8 @@ class FakeAuthRepository( private set var signOutCount = 0 private set + var deleteCount = 0 + private set var lastCredentials: Pair? = null private set @@ -47,6 +50,12 @@ class FakeAuthRepository( override fun currentUser(): AuthenticatedUser? = user + override suspend fun deleteCurrentUser() { + deleteCount++ + deleteError?.let { throw it } + user = null + } + override suspend fun signIn(email: String, password: String): AuthenticatedUser { signInCount++ lastCredentials = email to password diff --git a/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileViewModelTest.kt b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileViewModelTest.kt new file mode 100644 index 0000000..7f7757f --- /dev/null +++ b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileViewModelTest.kt @@ -0,0 +1,103 @@ +package duygu.yilmaz.campusnote.ui.auth + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import duygu.yilmaz.campusnote.testing.FakeAuthRepository +import duygu.yilmaz.campusnote.testing.FakeUserRepository +import duygu.yilmaz.campusnote.testing.MainDispatcherRule +import duygu.yilmaz.campusnote.testing.authenticatedUser +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * Profilsiz kalmış bir oturumun toparlanması. + * + * Bu ekran yalnızca kayıt yarıda kaldığında ve geri almanın da düştüğünde ortaya + * çıkan hesap için var. Yazdığı şeyin kaydınkiyle aynı olması gerekiyor: aynı uid, + * oturumdaki e-posta, seçilen bölüm — güvenlik kuralı e-postayı token'la + * karşılaştırdığı için istemcinin uydurduğu bir adres reddedilirdi. + */ +class CompleteProfileViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val authRepository = FakeAuthRepository( + authenticatedUser(uid = "uid-1", email = "duygu@ogr.akdeniz.edu.tr") + ) + private val userRepository = FakeUserRepository() + + private fun viewModel() = CompleteProfileViewModel(authRepository, userRepository) + + @Test + fun `profil oturumdaki kimlik ve adresle yazilir`() = + runTest(mainDispatcherRule.testDispatcher) { + val viewModel = viewModel() + viewModel.completeProfile("Bilgisayar Mühendisliği") + advanceUntilIdle() + + assertEquals(CompleteProfileUiState.Success, viewModel.uiState.value) + val saved = userRepository.savedUsers.single() + assertEquals("uid-1", saved.id) + assertEquals("duygu@ogr.akdeniz.edu.tr", saved.email) + assertEquals("Bilgisayar Mühendisliği", saved.department) + } + + @Test + fun `oturum yoksa yazma denenmez`() = + runTest(mainDispatcherRule.testDispatcher) { + authRepository.setUser(null) + + val viewModel = viewModel() + viewModel.completeProfile("Bilgisayar Mühendisliği") + advanceUntilIdle() + + assertEquals(CompleteProfileUiState.MissingSession, viewModel.uiState.value) + assertTrue(userRepository.savedUsers.isEmpty()) + } + + @Test + fun `yazma duserse hata gosterilir ve ekran kilitlenmez`() = + runTest(mainDispatcherRule.testDispatcher) { + userRepository.saveUserError = IllegalStateException("firestore down") + + val viewModel = viewModel() + viewModel.completeProfile("Bilgisayar Mühendisliği") + advanceUntilIdle() + + assertTrue(viewModel.uiState.value is CompleteProfileUiState.Error) + } + + /** Çift dokunuş ikinci bir profil yazmamalı. */ + @Test + fun `yukleme surerken ikinci istek yok sayilir`() = + runTest(mainDispatcherRule.testDispatcher) { + val viewModel = viewModel() + viewModel.completeProfile("Bilgisayar Mühendisliği") + viewModel.completeProfile("Matematik") + advanceUntilIdle() + + assertEquals(1, userRepository.savedUsers.size) + assertEquals("Bilgisayar Mühendisliği", userRepository.savedUsers.single().department) + } + + @Test + fun `ekran hangi hesabin tamamlandigini gosterir`() = + runTest(mainDispatcherRule.testDispatcher) { + assertEquals("duygu@ogr.akdeniz.edu.tr", viewModel().currentEmail()) + } + + @Test + fun `cikis oturumu kapatir`() = + runTest(mainDispatcherRule.testDispatcher) { + viewModel().signOut() + + assertEquals(1, authRepository.signOutCount) + } +} diff --git a/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/RegisterViewModelTest.kt b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/RegisterViewModelTest.kt index 9e7ecc2..30b6f4c 100644 --- a/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/RegisterViewModelTest.kt +++ b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/RegisterViewModelTest.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test @@ -103,4 +104,54 @@ class RegisterViewModelTest { assertEquals(AuthUiState.Idle, viewModel.uiState.value) } + + /** + * Kayıt iki ayrı sisteme yazıyor. Profil düşerse Auth hesabı geride kalır ve + * kimse ona ulaşamaz: kişi Firebase'de vardır, aynı adresle yeniden kayıt + * olamaz, profili olmadığı için uygulamayı da kullanamaz. + */ + @Test + fun `profil yazilamazsa auth hesabi geri alinir`() = + runTest(mainDispatcherRule.testDispatcher) { + userRepository.saveUserError = IllegalStateException("firestore down") + + val viewModel = viewModel() + viewModel.register("duygu@ogr.akdeniz.edu.tr", "sifre123", "Bilgisayar Mühendisliği") + advanceUntilIdle() + + assertEquals(1, authRepository.deleteCount) + assertNull(authRepository.currentUser()) + } + + @Test + fun `auth adimi duserse silinecek hesap olmadigi icin geri alma calismaz`() = + runTest(mainDispatcherRule.testDispatcher) { + authRepository.registerError = IllegalStateException("auth down") + + val viewModel = viewModel() + viewModel.register("duygu@ogr.akdeniz.edu.tr", "sifre123", "Bilgisayar Mühendisliği") + advanceUntilIdle() + + assertEquals(0, authRepository.deleteCount) + } + + /** + * Telafi de düşebilir. O zaman kullanıcıya gösterilen mesaj değişmemeli — sonuç + * onun açısından aynı, kayıt olmadı — ve hata yutulup ekran kilitlenmemeli. + * Ortada kalan hesabı CompleteProfileViewModel topluyor. + */ + @Test + fun `geri alma da duserse kullaniciya yine profil hatasi gosterilir`() = + runTest(mainDispatcherRule.testDispatcher) { + userRepository.saveUserError = IllegalStateException("firestore down") + authRepository.deleteError = IllegalStateException("delete failed") + + val viewModel = viewModel() + viewModel.register("duygu@ogr.akdeniz.edu.tr", "sifre123", "Bilgisayar Mühendisliği") + advanceUntilIdle() + + val state = viewModel.uiState.value + assertTrue(state is AuthUiState.Error) + assertEquals(AuthFailureStage.USER_PROFILE, (state as AuthUiState.Error).stage) + } } diff --git a/app/src/test/java/duygu/yilmaz/campusnote/ui/main/MainViewModelTest.kt b/app/src/test/java/duygu/yilmaz/campusnote/ui/main/MainViewModelTest.kt index bb00e0d..a6f0f7e 100644 --- a/app/src/test/java/duygu/yilmaz/campusnote/ui/main/MainViewModelTest.kt +++ b/app/src/test/java/duygu/yilmaz/campusnote/ui/main/MainViewModelTest.kt @@ -1,26 +1,105 @@ package duygu.yilmaz.campusnote.ui.main +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import duygu.yilmaz.campusnote.testing.FakeAuthRepository +import duygu.yilmaz.campusnote.testing.FakeUserRepository +import duygu.yilmaz.campusnote.testing.MainDispatcherRule import duygu.yilmaz.campusnote.testing.authenticatedUser +import duygu.yilmaz.campusnote.testing.userProfile +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue +import org.junit.Rule import org.junit.Test class MainViewModelTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + private val authRepository = FakeAuthRepository() + private val userRepository = FakeUserRepository() + + private fun viewModel() = MainViewModel(authRepository, userRepository) @Test fun `oturum acikken ana ekran kullaniciyi disari atmaz`() { authRepository.setUser(authenticatedUser()) - assertTrue(MainViewModel(authRepository).hasAuthenticatedUser()) + assertTrue(viewModel().hasAuthenticatedUser()) } @Test fun `oturum kapaliysa ana ekran giris ekranina yonlendirir`() { authRepository.setUser(null) - assertFalse(MainViewModel(authRepository).hasAuthenticatedUser()) + assertFalse(viewModel().hasAuthenticatedUser()) } + + @Test + fun `oturum yoksa rota giris ekranidir`() = + runTest(mainDispatcherRule.testDispatcher) { + authRepository.setUser(null) + + val viewModel = viewModel() + viewModel.resolveRoute() + advanceUntilIdle() + + assertEquals(MainRoute.SignedOut, viewModel.route.value) + } + + @Test + fun `profili olan oturum uygulamaya girer`() = + runTest(mainDispatcherRule.testDispatcher) { + authRepository.setUser(authenticatedUser(uid = "uid-1")) + userRepository.profile = userProfile(department = "Bilgisayar Mühendisliği") + + val viewModel = viewModel() + viewModel.resolveRoute() + advanceUntilIdle() + + assertEquals(MainRoute.Ready, viewModel.route.value) + } + + /** + * Auth hesabı olup profili olmayan oturum: kayıt yarıda kalmış ve geri alma da + * düşmüş demektir. O hesapla uygulamada dolaşmak çıkmaz — feed kilitli, yükleme + * bölüm istiyor — tek yolu profili tamamlamak. + */ + @Test + fun `profili olmayan oturum tamamlama ekranina gider`() = + runTest(mainDispatcherRule.testDispatcher) { + authRepository.setUser(authenticatedUser(uid = "uid-1")) + userRepository.profile = null + + val viewModel = viewModel() + viewModel.resolveRoute() + advanceUntilIdle() + + assertEquals(MainRoute.NeedsProfile, viewModel.route.value) + } + + /** + * "Profil okunamadı" ile "profil yok" aynı şey değil. Ağ düştüğü için okunamayan + * profil kullanıcıyı tamamlama ekranına atmamalı: orada da yazamaz, ve elindeki + * profil aslında yerinde duruyordur. Uygulama normal açılır, ekranlar hatayı + * kendi yeniden deneme mesajlarıyla gösterir. + */ + @Test + fun `profil okunamadiginda kullanici tamamlama ekranina atilmaz`() = + runTest(mainDispatcherRule.testDispatcher) { + authRepository.setUser(authenticatedUser(uid = "uid-1")) + userRepository.getUserError = IllegalStateException("network down") + + val viewModel = viewModel() + viewModel.resolveRoute() + advanceUntilIdle() + + assertEquals(MainRoute.Ready, viewModel.route.value) + } }