From 0e0e2b1de5d9d213b636bc36d05a2901bc4fff22 Mon Sep 17 00:00:00 2001 From: Duygu <84078855+duuyguyiilmaz@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:46:27 +0300 Subject: [PATCH] fix: stop registration leaving accounts nobody can use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration writes to two systems: a Firebase Auth account, then a Firestore profile. Only the first was durable. If the profile write failed, the account stayed — and an account without a profile is a dead end. The person is registered as far as Firebase is concerned, so they cannot sign up again with that address; and every screen needs the profile, so the feed stays locked, upload has no department to file under, and the profile tab is blank. There is no way out from inside the app. Two changes, because one is not enough. **The account is rolled back.** `RegisterViewModel` deletes the auth account it just created when the profile write fails. The rollback swallows its own errors: if deleting fails too, the message the user sees does not change, because from where they stand the outcome is identical — registration did not happen. What changes is that the account is still there, and that is what the second half is for. **A session with no profile is routed to a recovery screen.** `MainViewModel` resolves the session on startup into signed-out, ready, or needs-profile, and `CompleteProfileActivity` writes the missing document from the session's own uid and address — asking only for the department, since the security rule compares the address against `request.auth.token.email` anyway and a client-supplied one would be refused. "Cannot read the profile" and "there is no profile" are deliberately not the same branch. A dropped connection must not push someone into a screen where they cannot write either, so a read failure lets the app open normally and the existing per-screen retry messages handle it. This was not hypothetical: an account created earlier in this session for verification turned out to have no profile document, and it was recovered through this exact screen on the device — routed automatically on launch, department chosen, profile written, app opened. Why the original write left no document was not established; the logcat from that run had been cleared. That is itself the argument for the recovery screen rather than the rollback alone. Tests: 126 -> 139. The rollback, the rollback failing, and the auth step failing with nothing to roll back; the recovery ViewModel's write, missing session, double tap and failed write; and the three-way startup route including the read-failure branch that must not be mistaken for an absent profile. Co-Authored-By: Claude Opus 5 --- README.md | 19 ++- app/src/main/AndroidManifest.xml | 3 + .../data/repository/AuthRepository.kt | 15 ++ .../data/repository/FirebaseAuthRepository.kt | 4 + .../ui/auth/CompleteProfileActivity.kt | 131 ++++++++++++++ .../ui/auth/CompleteProfileViewModel.kt | 88 ++++++++++ .../campusnote/ui/auth/RegisterViewModel.kt | 24 +++ .../yilmaz/campusnote/ui/main/MainActivity.kt | 10 ++ .../campusnote/ui/main/MainViewModel.kt | 56 +++++- .../res/layout/activity_complete_profile.xml | 160 ++++++++++++++++++ app/src/main/res/values/strings.xml | 5 + .../campusnote/testing/FakeRepositories.kt | 9 + .../ui/auth/CompleteProfileViewModelTest.kt | 103 +++++++++++ .../ui/auth/RegisterViewModelTest.kt | 51 ++++++ .../campusnote/ui/main/MainViewModelTest.kt | 83 ++++++++- 15 files changed, 755 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileActivity.kt create mode 100644 app/src/main/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileViewModel.kt create mode 100644 app/src/main/res/layout/activity_complete_profile.xml create mode 100644 app/src/test/java/duygu/yilmaz/campusnote/ui/auth/CompleteProfileViewModelTest.kt 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) + } }