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)
+ }
}