Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Ensure Unique Usernames During Profile Updates #321

Closed
wants to merge 11 commits into from
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ package com.github.lookupgroup27.lookup.ui.profile

import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import com.github.lookupgroup27.lookup.model.profile.*
import com.github.lookupgroup27.lookup.ui.navigation.*
import com.github.lookupgroup27.lookup.model.profile.ProfileRepository
import com.github.lookupgroup27.lookup.model.profile.UserProfile
import com.github.lookupgroup27.lookup.ui.navigation.NavigationActions
import com.github.lookupgroup27.lookup.ui.navigation.Screen
import com.google.firebase.auth.FirebaseAuth
import org.junit.*
import org.mockito.Mockito.*
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.mockito.kotlin.*

@OptIn(ExperimentalCoroutinesApi::class)
class ProfileInformationScreenTest {

private lateinit var profileRepository: ProfileRepository
Expand All @@ -19,133 +25,132 @@ class ProfileInformationScreenTest {

@Before
fun setUp() {

profileRepository = mock(ProfileRepository::class.java)
profileRepository = mock()
profileViewModel = ProfileViewModel(profileRepository)
navigationActions = mock(NavigationActions::class.java)
firebaseAuth = mock(FirebaseAuth::class.java)

// Define navigation action behavior
`when`(navigationActions.currentRoute()).thenReturn(Screen.PROFILE_INFORMATION)
navigationActions = mock()
firebaseAuth = mock()

whenever(navigationActions.currentRoute()).thenReturn(Screen.PROFILE_INFORMATION)

// Common stubs for all tests:
// init always calls onSuccess immediately
whenever(profileRepository.init(any())).thenAnswer { it.getArgument<() -> Unit>(0).invoke() }
// Checking username always returns false (not taken)
whenever(profileRepository.isUsernameTaken(any(), any(), any())).thenAnswer {
val onResult = it.getArgument<(Boolean) -> Unit>(1)
onResult(false)
}
// updateUserProfile and deleteUserProfile always succeed
whenever(profileRepository.updateUserProfile(any(), any(), any())).thenAnswer {
it.getArgument<() -> Unit>(1).invoke()
}
whenever(profileRepository.deleteUserProfile(any(), any(), any())).thenAnswer {
it.getArgument<() -> Unit>(1).invoke()
}
}

@Test
fun displayAllComponents() {
// No profile needed here, just return null
whenever(profileRepository.getUserProfile(any(), any())).thenAnswer {
it.getArgument<(UserProfile?) -> Unit>(0).invoke(null)
}

composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) }

// Scroll to and check that the main components are displayed
composeTestRule.onNodeWithTag("editProfileScreen").assertIsDisplayed()
composeTestRule.onNodeWithTag("editProfileTitle").assertIsDisplayed()
composeTestRule.onNodeWithTag("goBackButton").assertIsDisplayed()

composeTestRule.onNodeWithTag("editProfileUsername").performScrollTo().assertIsDisplayed()

composeTestRule.onNodeWithTag("editProfileEmail").performScrollTo().assertIsDisplayed()

composeTestRule.onNodeWithTag("editProfileBio").performScrollTo().assertIsDisplayed()

composeTestRule.onNodeWithTag("profileSaveButton").performScrollTo().assertIsDisplayed()

composeTestRule.onNodeWithTag("profileLogout").performScrollTo().assertIsDisplayed()

// Check button texts after scrolling
// Check button texts
composeTestRule.onNodeWithTag("profileSaveButton").assertTextEquals("Save")
composeTestRule.onNodeWithTag("profileLogout").assertTextEquals("Sign out")
}

@Test
fun saveButtonDisabledWhenFieldsAreEmpty() {
whenever(profileRepository.getUserProfile(any(), any())).thenAnswer {
it.getArgument<(UserProfile?) -> Unit>(0).invoke(null)
}

composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) }

// Initially, all fields are empty, so the save button should be disabled
// Initially empty, save should be disabled
composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled()

// Fill in username, bio, and email
// Fill all fields
composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe")
composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("[email protected]")
composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio")

// Now all fields are filled, so the save button should be enabled
// Now enabled
composeTestRule.onNodeWithTag("profileSaveButton").assertIsEnabled()
}

@Test
fun logoutButtonWorks() {
whenever(profileRepository.getUserProfile(any(), any())).thenAnswer {
it.getArgument<(UserProfile?) -> Unit>(0).invoke(null)
}

composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) }

// Scroll to the sign-out button if it's off-screen, then click it
composeTestRule.onNodeWithTag("profileLogout").performScrollTo().performClick()
composeTestRule.waitForIdle()

// Verify that the navigation action to the landing screen was triggered
verify(navigationActions).navigateTo(Screen.LANDING)
}

@Test
fun saveButtonWorks() {
composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) }
// Start with no profile
whenever(profileRepository.getUserProfile(any(), any())).thenAnswer {
it.getArgument<(UserProfile?) -> Unit>(0).invoke(null)
}

// Assert: Save button is initially disabled
composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled()
composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) }

// Act: Fill in only the username
// Fill fields
composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe")
// Assert: Save button is still disabled
composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled()

// Act: Fill in email
composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("[email protected]")
// Assert: Save button is still disabled because bio is empty
composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled()

// Act: Fill in the bio
composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio")
// Assert: Save button should now be enabled
composeTestRule.onNodeWithTag("profileSaveButton").assertIsEnabled()

// Click Save
composeTestRule.onNodeWithTag("profileSaveButton").performClick()
composeTestRule.waitForIdle()

// Verify navigation after success
verify(navigationActions).navigateTo(Screen.PROFILE)
}

@Test
fun saveButtonDisabledWhenAllFieldsAreEmpty() {
whenever(profileRepository.getUserProfile(any(), any())).thenAnswer {
it.getArgument<(UserProfile?) -> Unit>(0).invoke(null)
}

composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) }

// Assert: Save button is disabled because no fields have been populated
// Nothing typed in, should be disabled
composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled()
}

@Test
fun deleteButtonDisabledWhenProfileIsNull() {
composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) }

// Assert: Delete button is disabled because the profile is null
composeTestRule.onNodeWithTag("profileDelete").assertIsNotEnabled()
}
// No profile returned
whenever(profileRepository.getUserProfile(any(), any())).thenAnswer {
it.getArgument<(UserProfile?) -> Unit>(0).invoke(null)
}

@Test
fun deleteButtonWorks() {
composeTestRule.setContent { ProfileInformationScreen(profileViewModel, navigationActions) }

// Assert: Save button is initially disabled
composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled()

// Act: Fill in only the username
composeTestRule.onNodeWithTag("editProfileUsername").performTextInput("JohnDoe")
// Assert: Save button is still disabled
composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled()

// Act: Fill in email
composeTestRule.onNodeWithTag("editProfileEmail").performTextInput("[email protected]")
// Assert: Save button is still disabled because bio is empty
composeTestRule.onNodeWithTag("profileSaveButton").assertIsNotEnabled()

// Act: Fill in the bio
composeTestRule.onNodeWithTag("editProfileBio").performTextInput("This is a bio")
composeTestRule.onNodeWithTag("profileSaveButton").performClick()
verify(navigationActions).navigateTo(Screen.PROFILE)
// Assert: Save button should now be enabled
composeTestRule.onNodeWithTag("profileDelete").assertIsEnabled()
// Scroll to the delete button if it's off-screen, then click it
composeTestRule.onNodeWithTag("profileDelete").performScrollTo().performClick()
verify(navigationActions).navigateTo(Screen.MENU)
// Profile is null, so delete is disabled
composeTestRule.onNodeWithTag("profileDelete").assertIsNotEnabled()
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// File: ProfileRepository.kt
package com.github.lookupgroup27.lookup.model.profile

interface ProfileRepository {
Expand All @@ -19,4 +20,13 @@ interface ProfileRepository {
)

fun getSelectedAvatar(userId: String, onSuccess: (Int?) -> Unit, onFailure: (Exception) -> Unit)

/**
* Checks if the given username is already taken by another user.
*
* @param username The username to check for uniqueness.
* @param onResult Callback with true if the username is taken, false otherwise.
* @param onFailure Callback with an exception if the operation fails.
*/
fun isUsernameTaken(username: String, onResult: (Boolean) -> Unit, onFailure: (Exception) -> Unit)
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// File: ProfileRepositoryFirestore.kt
package com.github.lookupgroup27.lookup.model.profile

import android.util.Log
Expand All @@ -18,7 +19,6 @@ class ProfileRepositoryFirestore(
private val auth: FirebaseAuth
) : ProfileRepository {

// private val auth = FirebaseAuth.getInstance()
private val collectionPath = "users"
private val usersCollection = db.collection(collectionPath)

Expand Down Expand Up @@ -122,6 +122,37 @@ class ProfileRepositoryFirestore(
.addOnFailureListener { exception -> onFailure(exception) }
}

/**
* Checks if the given username is already taken by another user.
*
* @param username The username to check for uniqueness.
* @param onResult Callback with true if the username is taken, false otherwise.
* @param onFailure Callback with an exception if the operation fails.
*/
override fun isUsernameTaken(
username: String,
onResult: (Boolean) -> Unit,
onFailure: (Exception) -> Unit
) {
val userId = auth.currentUser?.uid
if (userId != null) {
usersCollection
.whereEqualTo("username", username)
.get()
.addOnSuccessListener { querySnapshot ->
// Check if any document has the username and is not the current user
val taken = querySnapshot.documents.any { it.id != userId }
onResult(taken)
}
.addOnFailureListener { exception ->
Log.e("ProfileRepositoryFirestore", "Error checking username", exception)
onFailure(exception)
}
} else {
onFailure(Exception("User not logged in"))
}
}

private fun performFirestoreOperation(
task: Task<Void>,
onSuccess: () -> Unit,
Expand Down
Loading
Loading