From be2faef415858e009cc0cfddc489206ad52f81b2 Mon Sep 17 00:00:00 2001 From: Duygu <84078855+duuyguyiilmaz@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:35:29 +0300 Subject: [PATCH] fix: repair four defects the app hit on a live Firebase project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the app against real Firestore surfaced four problems that no test covered, because each one lives in the gap between a ViewModel's decision and what the user actually meets. **The password toggle showed the inverted icon.** The field used the legacy `passwordToggleEnabled` attribute, whose default icon reports the *action* (open eye while hidden, meaning "tap to reveal"). Read as state — which is how people read it — that is backwards. Both screens now use `endIconMode` with a `state_checked` selector: crossed-out eye while hidden, open eye while visible. **Every auth failure collapsed into "Tekrar deneyin."** The mapping matched on Firebase's English error *sentence* ("password is invalid", "no user record"). Those strings are not API; they changed, and the match silently stopped hitting — so a wrong password and a dropped connection produced the same useless text. Mapping now keys on `FirebaseAuthException.errorCode`. `ERROR_INVALID_CREDENTIAL` is the case that mattered most: with email enumeration protection on, Firebase returns it for both a wrong password and an unknown account, and nothing recognised it. **Leaving the app meant signing in again.** Firebase persists the session to disk, but the launcher only skipped the form when a "keep me signed in" checkbox had been ticked — so an untended box left the user signed in yet facing the login screen at every start. The box is gone; the session now lasts until the sign-out button in Profile, which is the only place that should end it. **The leaderboard error followed the user across tabs.** Its retry snackbar attaches to MainActivity's CoordinatorLayout, not to the fragment, so switching tabs destroyed the fragment and left the snackbar. Combined with LENGTH_INDEFINITE it never went away, and it sat over the upload form — the note could not be submitted at all. The snackbar is now held and dismissed in `onStop` and whenever a load succeeds. ProfileFragment carried the identical defect and is fixed the same way. Tests: 114 -> 129. `AuthErrorMessagesTest` pins each error code to its own message; `PasswordToggleTest` inflates both screens under Robolectric and asserts the icon reports state, since dropping `endIconDrawable` would leave a working button with the wrong icon and break nothing else. Not a code change, but worth recording: both composite indexes in firestore.indexes.json were missing from the live project. The feed only looked healthy because the contribution gate meant its query was never built; the first upload unlocked the gate and it failed too. Deploying step 6 of the README's Getting started is not optional. Co-Authored-By: Claude Opus 5 --- README.md | 6 +- .../campusnote/ui/auth/AuthErrorMessages.kt | 49 +++++++ .../campusnote/ui/auth/LoginActivity.kt | 36 ++--- .../campusnote/ui/auth/RegisterActivity.kt | 13 +- .../ui/leaderboard/LeaderboardFragment.kt | 28 +++- .../campusnote/ui/profile/ProfileFragment.kt | 22 +++- .../main/res/drawable/ic_password_toggle.xml | 15 +++ app/src/main/res/drawable/ic_visibility.xml | 10 ++ .../main/res/drawable/ic_visibility_off.xml | 10 ++ app/src/main/res/layout/activity_login.xml | 15 +-- app/src/main/res/layout/activity_register.xml | 5 +- app/src/main/res/values/strings.xml | 11 +- .../ui/auth/AuthErrorMessagesTest.kt | 108 +++++++++++++++ .../campusnote/ui/auth/PasswordToggleTest.kt | 123 ++++++++++++++++++ 14 files changed, 396 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessages.kt create mode 100644 app/src/main/res/drawable/ic_password_toggle.xml create mode 100644 app/src/main/res/drawable/ic_visibility.xml create mode 100644 app/src/main/res/drawable/ic_visibility_off.xml create mode 100644 app/src/test/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessagesTest.kt create mode 100644 app/src/test/java/duygu/yilmaz/campusnote/ui/auth/PasswordToggleTest.kt diff --git a/README.md b/README.md index 887fef0..e852692 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ emulator on API 24+. ./gradlew testDebugUnitTest ``` -114 JVM tests, no emulator and no network. They cover the layers where a bug would be +129 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. @@ -336,6 +336,8 @@ the app is built around — what the screen actually shows. | `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. | +| `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. | | `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. | @@ -377,7 +379,7 @@ test through a `FragmentFactory`, which is what lets the existing repository fak a real screen. The inverted-gate mutation above is not hypothetical: applying it turns five of these -six tests red and leaves all 96 other tests green. +six tests red and leaves every other test in the suite green. ### Rules tests diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessages.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessages.kt new file mode 100644 index 0000000..55db97e --- /dev/null +++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessages.kt @@ -0,0 +1,49 @@ +package duygu.yilmaz.campusnote.ui.auth + +import androidx.annotation.StringRes +import com.google.firebase.FirebaseNetworkException +import com.google.firebase.auth.FirebaseAuthException +import duygu.yilmaz.campusnote.R + +/** + * Giriş ve kayıt hatalarını kullanıcıya gösterilecek Türkçe metne çevirir. + * + * Eskiden bu eşleme Firebase'in İngilizce hata *cümlesinde* `contains` araması + * yapıyordu ("password is invalid", "no user record"). O cümleler API'nin bir parçası + * değil; SDK sürümleriyle değiştiler ve eşleşme sessizce bozuldu — her hata "Tekrar + * deneyin."e düştü, yani kullanıcı şifresini mi yanlış yazdığını yoksa internetinin mi + * gittiğini anlayamadı. [FirebaseAuthException.errorCode] ise sabit bir sözleşme. + */ +enum class AuthAction { SIGN_IN, REGISTER } + +fun authErrorMessage(exception: Throwable, action: AuthAction): Int = when (exception) { + is FirebaseNetworkException -> R.string.error_network + is FirebaseAuthException -> messageForAuthErrorCode(exception.errorCode, action) + else -> messageForAuthErrorCode(null, action) +} + +/** + * Kodu metne çeviren saf fonksiyon — Firebase tipine ihtiyaç duymadığı için JVM + * testinden doğrudan çağrılabiliyor. + * + * Bilinmeyen kod, eylemin kendi genel mesajına düşer: kayıt ile giriş aynı cümleyi + * paylaşamaz, "Giriş başarısız" kayıt ekranında yanlış olur. + */ +@StringRes +fun messageForAuthErrorCode(errorCode: String?, action: AuthAction): Int = when (errorCode) { + "ERROR_INVALID_EMAIL" -> R.string.error_invalid_email_format + "ERROR_EMAIL_ALREADY_IN_USE" -> R.string.register_error_email_in_use + "ERROR_WEAK_PASSWORD" -> R.string.register_error_weak_password + "ERROR_USER_NOT_FOUND" -> R.string.login_error_user_not_found + "ERROR_WRONG_PASSWORD" -> R.string.login_error_wrong_password + // Güncel Firebase, yanlış şifre ile olmayan hesabı bu tek kodda birleştiriyor. + "ERROR_INVALID_CREDENTIAL" -> R.string.login_error_invalid_credentials + "ERROR_USER_DISABLED" -> R.string.error_user_disabled + "ERROR_TOO_MANY_REQUESTS" -> R.string.error_too_many_requests + "ERROR_OPERATION_NOT_ALLOWED" -> R.string.error_operation_not_allowed + "ERROR_NETWORK_REQUEST_FAILED" -> R.string.error_network + else -> when (action) { + AuthAction.SIGN_IN -> R.string.login_error_generic + AuthAction.REGISTER -> R.string.register_error_generic + } +} diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/LoginActivity.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/LoginActivity.kt index ae80212..8c2dc32 100644 --- a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/LoginActivity.kt +++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/LoginActivity.kt @@ -1,7 +1,6 @@ package duygu.yilmaz.campusnote.ui.auth import android.content.Intent -import android.content.SharedPreferences import android.os.Bundle import android.view.View import android.widget.Toast @@ -16,13 +15,20 @@ class LoginActivity : AppCompatActivity() { private val viewModel: LoginViewModel by viewModels() private lateinit var binding: ActivityLoginBinding + /** + * Oturum, kullanıcı Profil'den çıkış yapana kadar açık kalır. + * + * Eskiden burada bir "Oturumu açık tut" kutusu vardı ve atlama yalnızca kutu + * işaretliyse yapılıyordu. Firebase oturumu zaten diske yazdığı için kutu + * işaretsizken kullanıcı hâlâ giriş yapmış oluyordu — uygulama sadece bunu yok + * sayıp formu gösteriyordu. Sonuç: uygulamadan yanlışlıkla çıkan biri her + * açılışta şifresini yeniden yazmak zorunda kalıyordu, üstelik "çıkış yapmış" + * da değildi. Kutu kaldırıldı; çıkış tek yerden, Profil'deki düğmeden yapılıyor. + */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - - val rememberMe = prefs.getBoolean(KEY_REMEMBER_ME, false) - if (rememberMe && viewModel.hasAuthenticatedUser()) { + if (viewModel.hasAuthenticatedUser()) { goToMainActivity() return } @@ -30,7 +36,7 @@ class LoginActivity : AppCompatActivity() { binding = ActivityLoginBinding.inflate(layoutInflater) setContentView(binding.root) - observeAuthState(prefs) + observeAuthState() setupClickListeners() animateViews() } @@ -50,14 +56,13 @@ class LoginActivity : AppCompatActivity() { } } - private fun observeAuthState(prefs: SharedPreferences) { + private fun observeAuthState() { viewModel.uiState.observe(this) { state -> when (state) { AuthUiState.Idle -> showLoading(false) AuthUiState.Loading -> showLoading(true) AuthUiState.Success -> { viewModel.resetState() - prefs.edit().putBoolean(KEY_REMEMBER_ME, binding.cbRememberMe.isChecked).apply() showLoading(false) Toast.makeText(this, R.string.login_welcome, Toast.LENGTH_SHORT).show() goToMainActivity() @@ -155,18 +160,7 @@ class LoginActivity : AppCompatActivity() { /** Firebase hata metinleri yerelleştirilmemiş geliyor; kullanıcıya Türkçesini gösteriyoruz. */ private fun showFirebaseError(e: Exception) { - val messageId = when { - e.message?.contains("no user record") == true -> - R.string.login_error_user_not_found - e.message?.contains("password is invalid") == true -> - R.string.login_error_wrong_password - e.message?.contains("badly formatted") == true -> - R.string.error_invalid_email_format - e.message?.contains("network") == true -> - R.string.error_network - else -> R.string.login_error_generic - } - Toast.makeText(this, messageId, Toast.LENGTH_LONG).show() + Toast.makeText(this, authErrorMessage(e, AuthAction.SIGN_IN), Toast.LENGTH_LONG).show() } private fun goToMainActivity() { @@ -175,8 +169,6 @@ class LoginActivity : AppCompatActivity() { } private companion object { - const val PREFS_NAME = "campusnote_prefs" - const val KEY_REMEMBER_ME = "remember_me" const val MIN_PASSWORD_LENGTH = 6 } } diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterActivity.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterActivity.kt index 69fb2d1..d271713 100644 --- a/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterActivity.kt +++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/auth/RegisterActivity.kt @@ -187,18 +187,7 @@ class RegisterActivity : AppCompatActivity() { /** Firebase hata metinleri yerelleştirilmemiş geliyor; kullanıcıya Türkçesini gösteriyoruz. */ private fun showFirebaseError(e: Exception) { - val messageId = when { - e.message?.contains("email address is already") == true -> - R.string.register_error_email_in_use - e.message?.contains("badly formatted") == true -> - R.string.error_invalid_email_format - e.message?.contains("weak password") == true -> - R.string.register_error_weak_password - e.message?.contains("network") == true -> - R.string.error_network - else -> R.string.register_error_generic - } - Toast.makeText(this, messageId, Toast.LENGTH_LONG).show() + Toast.makeText(this, authErrorMessage(e, AuthAction.REGISTER), Toast.LENGTH_LONG).show() } private fun goToMainActivity() { diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/leaderboard/LeaderboardFragment.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/leaderboard/LeaderboardFragment.kt index 13f1c26..07bca91 100644 --- a/app/src/main/java/duygu/yilmaz/campusnote/ui/leaderboard/LeaderboardFragment.kt +++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/leaderboard/LeaderboardFragment.kt @@ -19,6 +19,17 @@ class LeaderboardFragment : Fragment() { private lateinit var adapter: LeaderboardAdapter + /** + * Ekranda duran hata snackbar'ı. + * + * Referans tutuluyor çünkü snackbar bu fragment'ın değil, [MainActivity]'nin + * CoordinatorLayout'una bağlanıyor — sekme değiştirmek fragment'ı yok etse de + * snackbar'ı yok etmiyordu. [Snackbar.LENGTH_INDEFINITE] ile birleşince sıralama + * hatası kullanıcıyı yükleme ve profil sekmelerine kadar takip ediyor, hiç + * kapanmıyordu; index eksikken "Tekrar dene" de her seferinde yenisini açıyordu. + */ + private var errorSnackbar: Snackbar? = null + private val viewModel: LeaderboardViewModel by lazy { ViewModelProvider(this)[LeaderboardViewModel::class.java] } @@ -56,6 +67,8 @@ class LeaderboardFragment : Fragment() { } override fun onStop() { + // Dinleyiciyle birlikte hata mesajı da gitmeli: ikisi de bu ekrana ait. + dismissError() viewModel.stopLeaderboard() super.onStop() } @@ -106,12 +119,15 @@ class LeaderboardFragment : Fragment() { } LeaderboardUiState.Empty -> { + dismissError() adapter.refresh(emptyList()) binding.rvLeaderboard.visibility = View.GONE binding.layoutEmpty.visibility = View.VISIBLE } is LeaderboardUiState.Content -> { + // Yeniden deneme tuttuğunda eski hata kendiliğinden kapanmalı. + dismissError() adapter.refresh(state.entries) binding.rvLeaderboard.visibility = View.VISIBLE binding.layoutEmpty.visibility = View.GONE @@ -148,9 +164,17 @@ class LeaderboardFragment : Fragment() { * çizilir ve kapatılana kadar sekme değiştirmeyi engellerdi. */ private fun showRetryableError(message: String) { - Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE) + // Üst üste binen snackbar'lar yerine tek bir tane: hata her tekrar denemede + // yeniden geliyor, biriktirmenin anlamı yok. + dismissError() + errorSnackbar = Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE) .setAnchorView(R.id.bottomNav) .setAction(R.string.action_retry) { viewModel.startLeaderboard() } - .show() + .also { it.show() } + } + + private fun dismissError() { + errorSnackbar?.dismiss() + errorSnackbar = null } } diff --git a/app/src/main/java/duygu/yilmaz/campusnote/ui/profile/ProfileFragment.kt b/app/src/main/java/duygu/yilmaz/campusnote/ui/profile/ProfileFragment.kt index 7181518..9207a1c 100644 --- a/app/src/main/java/duygu/yilmaz/campusnote/ui/profile/ProfileFragment.kt +++ b/app/src/main/java/duygu/yilmaz/campusnote/ui/profile/ProfileFragment.kt @@ -27,6 +27,14 @@ class ProfileFragment : Fragment() { private lateinit var myNotesAdapter: PostAdapter + /** + * Ekranda duran hata snackbar'ı; sebebi için bkz. [showRetryableError]. + * + * Snackbar bu fragment'ın değil, [MainActivity]'nin CoordinatorLayout'una + * bağlanıyor, dolayısıyla sekme değiştirmek onu kapatmıyor. + */ + private var errorSnackbar: Snackbar? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -132,6 +140,8 @@ class ProfileFragment : Fragment() { } override fun onStop() { + // Dinleyiciyle birlikte hata mesajı da gitmeli: ikisi de bu ekrana ait. + dismissError() viewModel.stopProfile() super.onStop() } @@ -147,6 +157,8 @@ class ProfileFragment : Fragment() { } is ProfileUiState.Content -> { + // Yeniden deneme tuttuğunda eski hata kendiliğinden kapanmalı. + dismissError() binding.tvEmail.text = state.email binding.tvDepartment.text = state.department binding.tvAvatar.text = state.email.firstOrNull()?.uppercaseChar()?.toString() @@ -191,10 +203,16 @@ class ProfileFragment : Fragment() { * çizilir ve kapatılana kadar sekme değiştirmeyi engellerdi. */ private fun showRetryableError(message: String) { - Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE) + dismissError() + errorSnackbar = Snackbar.make(binding.root, message, Snackbar.LENGTH_INDEFINITE) .setAnchorView(R.id.bottomNav) .setAction(R.string.action_retry) { viewModel.startProfile() } - .show() + .also { it.show() } + } + + private fun dismissError() { + errorSnackbar?.dismiss() + errorSnackbar = null } private fun renderActionState(state: ProfileActionState) { diff --git a/app/src/main/res/drawable/ic_password_toggle.xml b/app/src/main/res/drawable/ic_password_toggle.xml new file mode 100644 index 0000000..6b81a5f --- /dev/null +++ b/app/src/main/res/drawable/ic_password_toggle.xml @@ -0,0 +1,15 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_visibility.xml b/app/src/main/res/drawable/ic_visibility.xml new file mode 100644 index 0000000..1f85494 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 0000000..13a1005 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml index 80ef524..a512a34 100644 --- a/app/src/main/res/layout/activity_login.xml +++ b/app/src/main/res/layout/activity_login.xml @@ -104,8 +104,9 @@ android:id="@+id/tilPassword" android:layout_width="match_parent" android:layout_height="wrap_content" - app:passwordToggleEnabled="true" - app:passwordToggleTint="@color/input_text" + app:endIconMode="password_toggle" + app:endIconDrawable="@drawable/ic_password_toggle" + app:endIconTint="@color/input_text" style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox" app:startIconDrawable="@drawable/ic_lock" app:startIconTint="@color/soft_orange" @@ -138,16 +139,6 @@ android:fontFamily="@font/poppins_regular"/> - Hata: %1$s İnternet bağlantınızı kontrol edin Geçersiz email formatı + Çok fazla deneme yapıldı. Bir süre sonra tekrar deneyin. + Bu hesap devre dışı bırakılmış + Email ile giriş bu projede kapalı %1$d oy İptal Tekrar dene @@ -46,11 +49,17 @@ Hesabına giriş yap - Oturumu açık tut Giriş yap Hoş geldin! Bu email ile kayıtlı kullanıcı bulunamadı Şifre hatalı + + Email veya şifre hatalı Giriş başarısız. Tekrar deneyin. diff --git a/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessagesTest.kt b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessagesTest.kt new file mode 100644 index 0000000..ef9e0e6 --- /dev/null +++ b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/AuthErrorMessagesTest.kt @@ -0,0 +1,108 @@ +package duygu.yilmaz.campusnote.ui.auth + +import duygu.yilmaz.campusnote.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** + * Bu eşlemenin bozulması sessiz: yanlış kod hâlâ *bir* mesaj döndürür, sadece işe + * yaramaz olanını. Eski hâli tam olarak böyle bozulmuştu — Firebase'in İngilizce + * cümlesi değişti, `contains` tutmaz oldu, her hata "Tekrar deneyin."e düştü ve + * hiçbir test kırılmadı. + */ +class AuthErrorMessagesTest { + + @Test + fun `yanlis sifre giris ekraninda kendi mesajini gosterir`() { + assertEquals( + R.string.login_error_wrong_password, + messageForAuthErrorCode("ERROR_WRONG_PASSWORD", AuthAction.SIGN_IN) + ) + } + + @Test + fun `kayitli olmayan kullanici kendi mesajini gosterir`() { + assertEquals( + R.string.login_error_user_not_found, + messageForAuthErrorCode("ERROR_USER_NOT_FOUND", AuthAction.SIGN_IN) + ) + } + + /** + * Asıl regresyon: e-posta sayım koruması açık bir projede Firebase artık yanlış + * şifre ile olmayan hesabı ayırmıyor, ikisine de bu kodu dönüyor. Eşleme bunu + * tanımazsa giriş hatalarının neredeyse tamamı genel mesaja düşer. + */ + @Test + fun `gecersiz kimlik bilgisi birlesik mesaja duser`() { + assertEquals( + R.string.login_error_invalid_credentials, + messageForAuthErrorCode("ERROR_INVALID_CREDENTIAL", AuthAction.SIGN_IN) + ) + } + + @Test + fun `kullanilan eposta kayit ekraninda kendi mesajini gosterir`() { + assertEquals( + R.string.register_error_email_in_use, + messageForAuthErrorCode("ERROR_EMAIL_ALREADY_IN_USE", AuthAction.REGISTER) + ) + } + + @Test + fun `zayif sifre kayit ekraninda kendi mesajini gosterir`() { + assertEquals( + R.string.register_error_weak_password, + messageForAuthErrorCode("ERROR_WEAK_PASSWORD", AuthAction.REGISTER) + ) + } + + @Test + fun `ag hatasi baglanti mesajini gosterir`() { + assertEquals( + R.string.error_network, + messageForAuthErrorCode("ERROR_NETWORK_REQUEST_FAILED", AuthAction.SIGN_IN) + ) + } + + @Test + fun `cok fazla deneme kendi mesajini gosterir`() { + assertEquals( + R.string.error_too_many_requests, + messageForAuthErrorCode("ERROR_TOO_MANY_REQUESTS", AuthAction.SIGN_IN) + ) + } + + @Test + fun `bilinmeyen kod eylemin genel mesajina duser`() { + assertEquals( + R.string.login_error_generic, + messageForAuthErrorCode("ERROR_SOMETHING_NEW", AuthAction.SIGN_IN) + ) + assertEquals( + R.string.register_error_generic, + messageForAuthErrorCode("ERROR_SOMETHING_NEW", AuthAction.REGISTER) + ) + } + + @Test + fun `kodsuz hata eylemin genel mesajina duser`() { + assertEquals( + R.string.login_error_generic, + messageForAuthErrorCode(null, AuthAction.SIGN_IN) + ) + } + + /** + * Genel mesaj iki ekran arasında paylaşılamaz: "Giriş başarısız" kayıt ekranında + * yanlış olur. Ayrı kaldıklarını sabitler. + */ + @Test + fun `giris ve kayit ayri genel mesajlar kullanir`() { + assertNotEquals( + messageForAuthErrorCode(null, AuthAction.SIGN_IN), + messageForAuthErrorCode(null, AuthAction.REGISTER) + ) + } +} diff --git a/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/PasswordToggleTest.kt b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/PasswordToggleTest.kt new file mode 100644 index 0000000..d25ce67 --- /dev/null +++ b/app/src/test/java/duygu/yilmaz/campusnote/ui/auth/PasswordToggleTest.kt @@ -0,0 +1,123 @@ +package duygu.yilmaz.campusnote.ui.auth + +import android.text.method.PasswordTransformationMethod +import android.view.LayoutInflater +import android.view.View +import android.widget.Checkable +import android.widget.CheckBox +import androidx.appcompat.view.ContextThemeWrapper +import com.google.android.material.textfield.TextInputLayout +import duygu.yilmaz.campusnote.R +import duygu.yilmaz.campusnote.databinding.ActivityLoginBinding +import duygu.yilmaz.campusnote.databinding.ActivityRegisterBinding +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +/** + * Şifre görünürlük düğmesinin ikonu *durumu* göstermeli: gizliyken üstü çizili göz, + * görünürken açık göz. + * + * Bu, Material'in varsayılanının tersi — varsayılan eylemi gösterir — ve düğme + * `endIconDrawable` ile özel bir selector'a bağlı olduğu için sessizce geri + * dönebilecek bir tercih: birisi `endIconDrawable` satırını silse düğme çalışmaya + * devam eder, sadece ikon yine ters görünür. Hiçbir ViewModel testi bunu görmez. + * + * `state_checked`, TextInputLayout'un password_toggle end icon'unda "şifre görünür" + * anlamına geliyor; test seçilen drawable yerine bu durumu doğruluyor, çünkü asıl + * sözleşme selector ile düğme arasında. + */ +@RunWith(RobolectricTestRunner::class) +@Config(qualifiers = "w411dp-h891dp") +class PasswordToggleTest { + + private fun themedInflater(): LayoutInflater { + val themed = ContextThemeWrapper( + RuntimeEnvironment.getApplication(), + R.style.Theme_CampusNote + ) + return LayoutInflater.from(themed) + } + + /** + * End icon bir [CheckBox] değil, Material'in kendi `CheckableImageButton`'ı; tipi + * kütüphanenin iç paketinde olduğu için [Checkable] arayüzü üzerinden okunuyor. + */ + private fun TextInputLayout.toggleButton(): View = + findViewById(com.google.android.material.R.id.text_input_end_icon) + + private val View.checked: Boolean get() = (this as Checkable).isChecked + + @Test + fun `giris ekraninda sifre once gizli ve ikon isaretsiz`() { + val binding = ActivityLoginBinding.inflate(themedInflater()) + + assertTrue( + "Şifre alanı gizli başlamalı", + binding.etPassword.transformationMethod is PasswordTransformationMethod + ) + assertFalse( + "Şifre gizliyken ikon işaretsiz olmalı — üstü çizili göz", + binding.tilPassword.toggleButton().checked + ) + } + + @Test + fun `giris ekraninda dugmeye basinca sifre gorunur ve ikon isaretlenir`() { + val binding = ActivityLoginBinding.inflate(themedInflater()) + + binding.tilPassword.toggleButton().performClick() + + assertFalse( + "Şifre alanı görünür olmalı", + binding.etPassword.transformationMethod is PasswordTransformationMethod + ) + assertTrue( + "Şifre görünürken ikon işaretli olmalı — açık göz", + binding.tilPassword.toggleButton().checked + ) + } + + @Test + fun `kayit ekrani ayni davranisi paylasir`() { + val binding = ActivityRegisterBinding.inflate(themedInflater()) + + assertFalse(binding.tilPassword.toggleButton().checked) + binding.tilPassword.toggleButton().performClick() + assertTrue(binding.tilPassword.toggleButton().checked) + } + + /** + * Selector'ın bağlı kaldığını sabitler: `endIconDrawable` düşerse Material kendi + * varsayılan ikonunu koyar ve görünüm sessizce eski ters hâline döner. + */ + @Test + fun `iki ekran da ozel selector ikonunu kullanir`() { + assertNotNull(ActivityLoginBinding.inflate(themedInflater()).tilPassword.endIconDrawable) + assertNotNull(ActivityRegisterBinding.inflate(themedInflater()).tilPassword.endIconDrawable) + } + + /** + * "Oturumu açık tut" kutusu kaldırıldı — oturum, kullanıcı Profil'den çıkana + * kadar zaten açık kalıyor. Kutunun geri gelmesi eski davranışın da geri + * gelmesi demek olurdu. + */ + @Test + fun `giris ekraninda oturumu acik tut kutusu yok`() { + val root = ActivityLoginBinding.inflate(themedInflater()).root + assertNull( + "Beni hatırla kutusu kaldırıldı", + root.findViewById( + root.resources.getIdentifier("cbRememberMe", "id", "duygu.yilmaz.campusnote") + ) + ) + assertEquals(0, root.resources.getIdentifier("login_keep_signed_in", "string", "duygu.yilmaz.campusnote")) + } +}