From 2bc3a78681e3a9dc160c82144be3b67d8971b4bf Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:29:12 -0700 Subject: [PATCH] feat(auth): complete the email-change flow with verify and undo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plumb the two endpoints that finish an email change and were previously web-only, so an Android user can start and finish the change in the app: - POST /api/auth/verify-email-change — confirm the new address - POST /api/auth/undo-email-change — revert a change the user did not make Both are unauthenticated in the OpenAPI spec (x-auth-type: none, empty security), so the repository submits the token without requiring a session — the point of the undo link is that whoever reaches for it may already be locked out. Deep links follow the app's existing NotificationLaunch pattern: a pure EmailChangeLink parser maps the emailed URL onto an in-app route, MainActivity resolves the VIEW intent through AuthRoutes.routeForEmailChangeLink, and the nav host navigates there whether or not the app started signed in. Manifest intent filters cover both https://interlinedlist.com/{verify,undo}-email-change and the interlinedlist:// equivalents. Malformed links (no token, blank token, foreign host, plain /verify-email) resolve to nothing and the screen reports an invalid link rather than submitting or reporting success. Account settings gains a pending-change banner driven by the live pendingEmail field on GET /api/user: it shows the address awaiting confirmation, offers a resend, and clears once the server reports the change confirmed or undone. The API exposes no endpoint that cancels a pending change (DELETE on /api/user/change-email/request is 405 and no /cancel route exists), so the banner states plainly that the emailed undo link is how to stop it rather than shipping a button that does nothing. Tests: deep-link parsing for all four link shapes plus the malformed cases; verify/undo ViewModel paths including the server's own error message; the repository request shapes over MockWebServer; the pending state rendering, clearing and resend on the account ViewModel; Compose coverage for the result screen copy and the pending banner. Closes #72 --- app/src/main/AndroidManifest.xml | 7 +- .../interlinedlist/android/MainActivity.kt | 6 + .../navigation/InterlinedListNavHost.kt | 8 + .../feature/auth/ui/EmailChangeScreenTest.kt | 96 +++++++++ .../feature/auth/data/AuthRepository.kt | 13 ++ .../auth/data/DefaultAuthRepository.kt | 12 ++ .../feature/auth/data/remote/AuthApi.kt | 17 ++ .../auth/data/remote/dto/AuthRequests.kt | 20 ++ .../feature/auth/nav/AuthNavigation.kt | 72 ++++++- .../feature/auth/nav/EmailChangeLink.kt | 114 +++++++++++ .../feature/auth/ui/EmailChangeScreen.kt | 192 ++++++++++++++++++ .../feature/auth/ui/EmailChangeViewModel.kt | 112 ++++++++++ .../auth/data/DefaultAuthRepositoryTest.kt | 62 ++++++ .../feature/auth/nav/EmailChangeLinkTest.kt | 122 +++++++++++ .../auth/ui/EmailChangeViewModelTest.kt | 100 +++++++++ .../feature/auth/ui/FakeAuthRepository.kt | 14 ++ .../feature/profile/ui/AccountScreensTest.kt | 50 +++++ .../profile/data/DefaultProfileRepository.kt | 7 + .../feature/profile/data/ProfileRepository.kt | 8 + .../data/remote/dto/ProfileResponses.kt | 2 + .../profile/data/remote/dto/ProfileUserDto.kt | 6 + .../ui/account/AccountSettingsScreen.kt | 86 +++++++- .../ui/account/AccountSettingsViewModel.kt | 67 +++++- .../data/DefaultProfileRepositoryTest.kt | 30 +++ .../ui/AccountSettingsViewModelTest.kt | 96 +++++++++ .../profile/ui/FakeProfileRepository.kt | 14 ++ 26 files changed, 1320 insertions(+), 13 deletions(-) create mode 100644 feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreenTest.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLink.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreen.kt create mode 100644 feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModel.kt create mode 100644 feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLinkTest.kt create mode 100644 feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModelTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b99fdcc..03d93a3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,7 +27,8 @@ @@ -38,6 +39,8 @@ + + @@ -49,6 +52,8 @@ + + diff --git a/app/src/main/java/com/interlinedlist/android/MainActivity.kt b/app/src/main/java/com/interlinedlist/android/MainActivity.kt index 01ae740..3a58463 100644 --- a/app/src/main/java/com/interlinedlist/android/MainActivity.kt +++ b/app/src/main/java/com/interlinedlist/android/MainActivity.kt @@ -12,6 +12,7 @@ import com.interlinedlist.android.core.datastore.SessionStore import com.interlinedlist.android.core.datastore.ThemeMode import com.interlinedlist.android.core.datastore.ThemeSettingsStore import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.auth.nav.AuthRoutes import com.interlinedlist.android.navigation.InterlinedListNavHost import com.interlinedlist.android.navigation.NotificationLaunch import dagger.hilt.android.AndroidEntryPoint @@ -35,6 +36,10 @@ class MainActivity : ComponentActivity() { // A tapped system notification launches us with deep-link extras; resolve the // pending in-app route so the signed-in shell can navigate straight to it. val notificationRoute = NotificationLaunch.fromIntent(intent)?.route + // A tapped email-change link (confirm or undo) launches us with the token in + // the VIEW intent's data. Both endpoints behind it are unauthenticated, so the + // route resolves regardless of whether a session exists. + val emailChangeRoute = AuthRoutes.routeForEmailChangeLink(intent?.dataString) enableEdgeToEdge() setContent { val themeMode by themeSettingsStore.themeMode.collectAsStateWithLifecycle() @@ -47,6 +52,7 @@ class MainActivity : ComponentActivity() { InterlinedListNavHost( startLoggedIn = startLoggedIn, notificationRoute = notificationRoute, + emailChangeRoute = emailChangeRoute, ) } } diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 3d9827b..80e8529 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -195,6 +195,7 @@ private enum class HomeTab(val route: String, val label: String, val icon: Image fun InterlinedListNavHost( startLoggedIn: Boolean, notificationRoute: String? = null, + emailChangeRoute: String? = null, ) { val navController = rememberNavController() NavHost( @@ -230,6 +231,13 @@ fun InterlinedListNavHost( ) } } + + // A tapped email-change link resolves to a route in the auth graph, which is + // registered above whether or not the app started signed in — so the confirm and + // undo screens are reachable straight from the email either way. + LaunchedEffect(Unit) { + emailChangeRoute?.let { route -> runCatching { navController.navigate(route) } } + } } /** diff --git a/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreenTest.kt b/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreenTest.kt new file mode 100644 index 0000000..ed5691e --- /dev/null +++ b/feature/auth/src/androidTest/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreenTest.kt @@ -0,0 +1,96 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.auth.nav.EmailChangeAction +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * The confirm/undo result screen. The undo half is a security affordance, so the + * copy has to say plainly that the previous address was restored — these assertions + * guard that wording against being softened into a generic "done". + */ +@RunWith(AndroidJUnit4::class) +class EmailChangeScreenTest { + + @get:Rule + val composeRule = createComposeRule() + + private fun setScreen(state: EmailChangeUiState, onDone: () -> Unit = {}) { + composeRule.setContent { + InterlinedListTheme { EmailChangeScreen(state = state, onDone = onDone) } + } + } + + @Test + fun verifySuccess_saysTheAccountNowUsesTheNewAddress() { + setScreen(EmailChangeUiState(EmailChangeAction.VERIFY, EmailChangeStatus.DONE)) + + composeRule.onNodeWithTag(EmailChangeTestTags.HEADING).assertIsDisplayed() + composeRule.onNodeWithTag(EmailChangeTestTags.BODY).assertIsDisplayed() + assertThat(composeRule.textOf(EmailChangeTestTags.HEADING)).contains("updated") + } + + @Test + fun undoSuccess_spellsOutWhatWasRestoredAndWhatToDoNext() { + setScreen(EmailChangeUiState(EmailChangeAction.UNDO, EmailChangeStatus.DONE)) + + assertThat(composeRule.textOf(EmailChangeTestTags.HEADING)).contains("undone") + val body = composeRule.textOf(EmailChangeTestTags.BODY) + assertThat(body).contains("restored") + assertThat(body).contains("change your password") + } + + @Test + fun failure_showsTheServersOwnMessage() { + setScreen( + EmailChangeUiState( + action = EmailChangeAction.VERIFY, + status = EmailChangeStatus.FAILED, + message = "That email is already in use", + ), + ) + + assertThat(composeRule.textOf(EmailChangeTestTags.BODY)).isEqualTo("That email is already in use") + } + + @Test + fun invalidLink_saysNothingChangedAndOffersNoRetry() { + setScreen(EmailChangeUiState(EmailChangeAction.UNDO, EmailChangeStatus.INVALID_LINK)) + + assertThat(composeRule.textOf(EmailChangeTestTags.BODY)).contains("nothing was changed") + composeRule.onNodeWithTag(EmailChangeTestTags.PROGRESS).assertDoesNotExist() + } + + @Test + fun working_showsProgressAndNoDoneButton() { + setScreen(EmailChangeUiState(EmailChangeAction.VERIFY, EmailChangeStatus.WORKING)) + + composeRule.onNodeWithTag(EmailChangeTestTags.PROGRESS).assertIsDisplayed() + composeRule.onNodeWithTag(EmailChangeTestTags.DONE).assertDoesNotExist() + } + + @Test + fun done_invokesTheCallback() { + var done = 0 + setScreen(EmailChangeUiState(EmailChangeAction.UNDO, EmailChangeStatus.DONE)) { done++ } + + composeRule.onNodeWithTag(EmailChangeTestTags.DONE).performClick() + + assertThat(done).isEqualTo(1) + } +} + +/** Reads the text semantics of the node tagged [tag]. */ +private fun androidx.compose.ui.test.junit4.ComposeContentTestRule.textOf(tag: String): String = + onNodeWithTag(tag) + .fetchSemanticsNode() + .config[androidx.compose.ui.semantics.SemanticsProperties.Text] + .joinToString(separator = "") { it.text } diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt index 6555799..7a2c1e2 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/AuthRepository.kt @@ -40,6 +40,19 @@ interface AuthRepository { /** Resends the verification email to the signed-in (unverified) user. */ suspend fun resendVerificationEmail(): ApiResult + /** + * Completes a pending email change with the token from the link mailed to the + * new address (`POST /api/auth/verify-email-change`). Needs no session. + */ + suspend fun verifyEmailChange(token: String): ApiResult + + /** + * Reverts an email change with the token from the link mailed to the previous + * address (`POST /api/auth/undo-email-change`). Needs no session, by design: + * the person reaching for it may no longer be able to sign in. + */ + suspend fun undoEmailChange(token: String): ApiResult + /** Clears the persisted session and cached user. */ suspend fun logout() } diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt index 036a4fe..c38c3fa 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepository.kt @@ -14,6 +14,8 @@ import com.interlinedlist.android.feature.auth.data.remote.AuthApi import com.interlinedlist.android.feature.auth.data.remote.dto.ForgotPasswordRequest import com.interlinedlist.android.feature.auth.data.remote.dto.RegisterRequest import com.interlinedlist.android.feature.auth.data.remote.dto.ResetPasswordRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.UndoEmailChangeRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailChangeRequest import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailRequest import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -84,6 +86,16 @@ class DefaultAuthRepository @Inject constructor( safeApiCall(json) { authApi.sendVerificationEmail() } } + override suspend fun verifyEmailChange(token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { authApi.verifyEmailChange(VerifyEmailChangeRequest(token)) } + } + + override suspend fun undoEmailChange(token: String): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { authApi.undoEmailChange(UndoEmailChangeRequest(token)) } + } + override suspend fun logout() = withContext(dispatchers.io) { sessionStore.clear() userDao.clear() diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt index a064be9..52d9054 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/AuthApi.kt @@ -3,6 +3,8 @@ package com.interlinedlist.android.feature.auth.data.remote import com.interlinedlist.android.feature.auth.data.remote.dto.ForgotPasswordRequest import com.interlinedlist.android.feature.auth.data.remote.dto.RegisterRequest import com.interlinedlist.android.feature.auth.data.remote.dto.ResetPasswordRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.UndoEmailChangeRequest +import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailChangeRequest import com.interlinedlist.android.feature.auth.data.remote.dto.VerifyEmailRequest import retrofit2.http.Body import retrofit2.http.POST @@ -38,4 +40,19 @@ interface AuthApi { /** Resends the verification email to the signed-in (unverified) user. */ @POST("api/auth/send-verification-email") suspend fun sendVerificationEmail() + + /** + * Completes a pending email change with the token from the link mailed to the + * new address. Unauthenticated, so it also works from a signed-out app. + */ + @POST("api/auth/verify-email-change") + suspend fun verifyEmailChange(@Body body: VerifyEmailChangeRequest) + + /** + * Reverts an email change with the token from the link mailed to the previous + * address. Unauthenticated by design — the account owner may have already lost + * access when they reach for it. + */ + @POST("api/auth/undo-email-change") + suspend fun undoEmailChange(@Body body: UndoEmailChangeRequest) } diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt index 1d94363..6e2dece 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/data/remote/dto/AuthRequests.kt @@ -40,3 +40,23 @@ data class ResetPasswordRequest( data class VerifyEmailRequest( val token: String, ) + +/** + * `POST /api/auth/verify-email-change` — confirms a pending email change with the + * token from the message sent to the *new* address. Unauthenticated + * (`x-auth-type: none` in the OpenAPI spec), so the tap works from a signed-out app. + */ +@Serializable +data class VerifyEmailChangeRequest( + val token: String, +) + +/** + * `POST /api/auth/undo-email-change` — reverts an email change using the token from + * the message sent to the *previous* address. Also unauthenticated by design: the + * whole point is that somebody who has lost access to the account can still undo it. + */ +@Serializable +data class UndoEmailChangeRequest( + val token: String, +) diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt index 40e3413..8494a5d 100644 --- a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/AuthNavigation.kt @@ -11,6 +11,8 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.navigation.navDeepLink +import com.interlinedlist.android.feature.auth.ui.EMAIL_CHANGE_ACTION_ARG +import com.interlinedlist.android.feature.auth.ui.EmailChangeRoute import com.interlinedlist.android.feature.auth.ui.ForgotPasswordRoute import com.interlinedlist.android.feature.auth.ui.LoginRoute import com.interlinedlist.android.feature.auth.ui.RegisterRoute @@ -41,18 +43,50 @@ object AuthRoutes { * `` maps `https://interlinedlist.com/reset-password` and * `/verify-email` onto these so `NavController.handleDeepLink` lands directly * on the matching screen with its `token` populated. + * + * Built lazily: `NavDeepLink` parses its pattern with `android.net.Uri`, and the + * plain route helpers on this object are covered by JVM unit tests that must not + * drag the Android framework in just by touching the object. */ - val RESET_DEEP_LINKS: List = listOf( - navDeepLink { uriPattern = "https://interlinedlist.com/reset-password?$TOKEN_ARG={$TOKEN_ARG}" }, - navDeepLink { uriPattern = "interlinedlist://reset-password?$TOKEN_ARG={$TOKEN_ARG}" }, - ) - val VERIFY_DEEP_LINKS: List = listOf( - navDeepLink { uriPattern = "https://interlinedlist.com/verify-email?$TOKEN_ARG={$TOKEN_ARG}" }, - navDeepLink { uriPattern = "interlinedlist://verify-email?$TOKEN_ARG={$TOKEN_ARG}" }, - ) + val RESET_DEEP_LINKS: List by lazy { + listOf( + navDeepLink { uriPattern = "https://interlinedlist.com/reset-password?$TOKEN_ARG={$TOKEN_ARG}" }, + navDeepLink { uriPattern = "interlinedlist://reset-password?$TOKEN_ARG={$TOKEN_ARG}" }, + ) + } + val VERIFY_DEEP_LINKS: List by lazy { + listOf( + navDeepLink { uriPattern = "https://interlinedlist.com/verify-email?$TOKEN_ARG={$TOKEN_ARG}" }, + navDeepLink { uriPattern = "interlinedlist://verify-email?$TOKEN_ARG={$TOKEN_ARG}" }, + ) + } fun reset(token: String) = "auth/reset?$TOKEN_ARG=$token" fun verify(token: String) = "auth/verify?$TOKEN_ARG=$token" + + /** + * Confirm-or-undo destination for the two email-change links. Both halves share + * one screen and differ only by the `action` argument, so the emailed + * `/verify-email-change` and `/undo-email-change` links map onto the same route. + */ + const val EMAIL_CHANGE = + "auth/email-change?$EMAIL_CHANGE_ACTION_ARG={$EMAIL_CHANGE_ACTION_ARG}&$TOKEN_ARG={$TOKEN_ARG}" + + fun emailChange(action: EmailChangeAction, token: String) = + "auth/email-change?$EMAIL_CHANGE_ACTION_ARG=${action.name}&$TOKEN_ARG=$token" + + /** + * Maps a tapped email-change link onto an in-app route, or returns null when the + * URI is not one of those links (or carries no token). + * + * The app resolves the launch intent through here — the same way a tapped + * notification goes through `NotificationLaunch` — rather than relying on implicit + * `navDeepLink` matching, so this security-sensitive entry point is exercised by + * plain unit tests. Both endpoints behind it are unauthenticated, so the route + * resolves whether or not the app has a session. + */ + fun routeForEmailChangeLink(uri: String?): String? = + EmailChangeLink.parse(uri)?.let { emailChange(it.action, it.token) } } /** @@ -104,6 +138,28 @@ fun NavGraphBuilder.authGraph( ) } + composable( + route = AuthRoutes.EMAIL_CHANGE, + arguments = listOf( + navArgument(EMAIL_CHANGE_ACTION_ARG) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + navArgument(AuthRoutes.TOKEN_ARG) { + type = NavType.StringType + nullable = true + defaultValue = null + }, + ), + ) { + EmailChangeRoute( + // Reached from an email while signed in *or* signed out: go back to + // whatever was underneath, falling back to Login when nothing is. + onDone = { if (!navController.popBackStack()) navController.popToLogin() }, + ) + } + composable( route = AuthRoutes.VERIFY, arguments = listOf( diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLink.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLink.kt new file mode 100644 index 0000000..27205f5 --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLink.kt @@ -0,0 +1,114 @@ +package com.interlinedlist.android.feature.auth.nav + +import java.net.URI +import java.net.URLDecoder + +/** Which half of the email-change flow an emailed link completes. */ +enum class EmailChangeAction { + /** Confirm the new address — the link mailed to the *new* inbox. */ + VERIFY, + + /** Revert the change — the link mailed to the *previous* inbox. */ + UNDO, +} + +/** + * A parsed email-change deep link: which [action] the tapped link performs and the + * one-time [token] it carries. + * + * Kept as plain JVM code (no `android.net.Uri`) so the parsing rules are covered by + * fast unit tests — this is the entry point for a *security* action reached from an + * email, so silently mis-parsing it is not acceptable. + */ +data class EmailChangeLink( + val action: EmailChangeAction, + val token: String, +) { + companion object { + + /** Query parameter carrying the one-time token on both links. */ + const val TOKEN_PARAM = "token" + + /** Web path for the "confirm the new address" link. */ + const val VERIFY_PATH = "verify-email-change" + + /** Web path for the "this wasn't me — undo it" link. */ + const val UNDO_PATH = "undo-email-change" + + /** Host of the web links; also matched with a `www.` prefix. */ + const val WEB_HOST = "interlinedlist.com" + + /** Custom scheme the app registers for the same two links. */ + const val APP_SCHEME = "interlinedlist" + + private val WEB_SCHEMES = setOf("https", "http") + + /** + * Parses [uri] into an [EmailChangeLink], or returns null when it is not one + * of the two email-change links or carries no usable token. + * + * Recognised shapes (scheme/host case-insensitive, trailing slash and extra + * query parameters tolerated): + * - `https://interlinedlist.com/verify-email-change?token=…` + * - `https://interlinedlist.com/undo-email-change?token=…` + * - `interlinedlist://verify-email-change?token=…` + * - `interlinedlist://undo-email-change?token=…` + * + * Anything malformed — a missing or blank token, a foreign host, an unrelated + * path, or a string that is not a URI at all — yields null rather than an + * exception or a half-populated link. + */ + fun parse(uri: String?): EmailChangeLink? { + val trimmed = uri?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val parsed = runCatching { URI(trimmed) }.getOrNull() ?: return null + + val action = parsed.actionOrNull() ?: return null + val token = parsed.rawQuery.queryParam(TOKEN_PARAM)?.takeIf { it.isNotBlank() } ?: return null + return EmailChangeLink(action, token) + } + + /** + * The action this URI targets, or null when it is not an email-change link. + * Web links carry the action in the path; custom-scheme links carry it in the + * authority (`interlinedlist://verify-email-change`). + */ + private fun URI.actionOrNull(): EmailChangeAction? { + val scheme = scheme?.lowercase() ?: return null + val target = when { + scheme in WEB_SCHEMES -> { + val host = host?.lowercase()?.removePrefix("www.") ?: return null + if (host != WEB_HOST) return null + path.orEmpty().trim('/') + } + scheme == APP_SCHEME -> { + // `interlinedlist://verify-email-change?token=…` — the action sits in + // the authority; tolerate it appearing as a leading path segment too. + val authority = host ?: authority + (authority.orEmpty() + path.orEmpty()).trim('/') + } + else -> return null + } + return when (target.lowercase()) { + VERIFY_PATH -> EmailChangeAction.VERIFY + UNDO_PATH -> EmailChangeAction.UNDO + else -> null + } + } + + /** Reads a single percent-decoded query parameter out of a raw query string. */ + private fun String?.queryParam(name: String): String? = this + ?.split('&') + ?.firstNotNullOfOrNull { pair -> + val separator = pair.indexOf('=') + if (separator <= 0) return@firstNotNullOfOrNull null + val key = pair.substring(0, separator).decodeOrNull() + if (!key.equals(name, ignoreCase = true)) return@firstNotNullOfOrNull null + pair.substring(separator + 1).decodeOrNull() + } + + /** Percent-decodes a query component, falling back to the raw text. */ + private fun String.decodeOrNull(): String? = + runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this) + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreen.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreen.kt new file mode 100644 index 0000000..6cc2d5e --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeScreen.kt @@ -0,0 +1,192 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.interlinedlist.android.core.designsystem.component.InterlinedListWordmark +import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.feature.auth.nav.EmailChangeAction + +/** Stable test tags for the email-change confirm / undo screen. */ +object EmailChangeTestTags { + const val HEADING = "emailChangeHeading" + const val BODY = "emailChangeBody" + const val PROGRESS = "emailChangeProgress" + const val DONE = "emailChangeDone" +} + +/** Hilt-wired entry point; action + token arrive as deep-link nav arguments. */ +@Composable +fun EmailChangeRoute( + onDone: () -> Unit, + modifier: Modifier = Modifier, + viewModel: EmailChangeViewModel = hiltViewModel(), +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + EmailChangeScreen(state = state, onDone = onDone, modifier = modifier) +} + +/** + * Stateless result screen for both emailed email-change links. + * + * The copy is deliberately explicit about *which* address won: an undo is a security + * affordance, so the screen spells out that the previous address has been restored + * and tells a user who did not start the change what to do next. + */ +@Composable +fun EmailChangeScreen( + state: EmailChangeUiState, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold(modifier = modifier.fillMaxSize()) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + InterlinedListWordmark() + Spacer(Modifier.height(24.dp)) + + Text( + text = headingFor(state), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.testTag(EmailChangeTestTags.HEADING), + ) + + if (state.status == EmailChangeStatus.WORKING) { + Spacer(Modifier.height(16.dp)) + CircularProgressIndicator( + modifier = Modifier.size(28.dp).testTag(EmailChangeTestTags.PROGRESS), + ) + } + + val failed = state.status == EmailChangeStatus.FAILED + val body = if (failed) state.message ?: bodyFor(state) else bodyFor(state) + if (body != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = if (failed || state.status == EmailChangeStatus.INVALID_LINK) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().testTag(EmailChangeTestTags.BODY), + ) + } + + if (state.status != EmailChangeStatus.WORKING) { + Spacer(Modifier.height(24.dp)) + Button( + onClick = onDone, + modifier = Modifier.fillMaxWidth().testTag(EmailChangeTestTags.DONE), + ) { + Text("Done") + } + } + } + } +} + +/** Heading for the current action + status combination. */ +private fun headingFor(state: EmailChangeUiState): String = when (state.action) { + EmailChangeAction.VERIFY -> when (state.status) { + EmailChangeStatus.WORKING -> "Confirming your new email…" + EmailChangeStatus.DONE -> "Email address updated" + EmailChangeStatus.FAILED -> "Couldn't confirm the new email" + EmailChangeStatus.INVALID_LINK -> "This confirmation link is incomplete" + } + EmailChangeAction.UNDO -> when (state.status) { + EmailChangeStatus.WORKING -> "Undoing the email change…" + EmailChangeStatus.DONE -> "Email change undone" + EmailChangeStatus.FAILED -> "Couldn't undo the email change" + EmailChangeStatus.INVALID_LINK -> "This undo link is incomplete" + } +} + +/** Supporting copy; the failure case prefers the server's own message. */ +private fun bodyFor(state: EmailChangeUiState): String? = when (state.action) { + EmailChangeAction.VERIFY -> when (state.status) { + EmailChangeStatus.WORKING -> null + EmailChangeStatus.DONE -> + "Your account now uses your new email address. Sign in with it from now on." + EmailChangeStatus.FAILED -> + "The link may have expired or already been used. Request the change again " + + "from Account settings." + EmailChangeStatus.INVALID_LINK -> + "It's missing its security token, so nothing was changed. Open the link " + + "straight from the email instead of copying it by hand." + } + EmailChangeAction.UNDO -> when (state.status) { + EmailChangeStatus.WORKING -> + "This restores the email address your account had before the change." + EmailChangeStatus.DONE -> + "Your account email has been restored to the previous address and the " + + "change was cancelled. If you did not request that change, someone " + + "else may have access to your account — change your password now." + EmailChangeStatus.FAILED -> + "The link may have expired or already been used. Contact support if you " + + "did not request the email change." + EmailChangeStatus.INVALID_LINK -> + "It's missing its security token, so nothing was changed. Open the link " + + "straight from the email instead of copying it by hand." + } +} + +@Preview(showBackground = true) +@Composable +private fun EmailChangeVerifiedPreview() { + InterlinedListTheme { + EmailChangeScreen( + state = EmailChangeUiState( + action = EmailChangeAction.VERIFY, + status = EmailChangeStatus.DONE, + ), + onDone = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun EmailChangeUndonePreview() { + InterlinedListTheme { + EmailChangeScreen( + state = EmailChangeUiState( + action = EmailChangeAction.UNDO, + status = EmailChangeStatus.DONE, + ), + onDone = {}, + ) + } +} diff --git a/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModel.kt b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModel.kt new file mode 100644 index 0000000..bf07d3f --- /dev/null +++ b/feature/auth/src/main/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModel.kt @@ -0,0 +1,112 @@ +package com.interlinedlist.android.feature.auth.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.feature.auth.data.AuthRepository +import com.interlinedlist.android.feature.auth.nav.EmailChangeAction +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Nav argument key for which half of the flow the tapped link completes. */ +const val EMAIL_CHANGE_ACTION_ARG = "action" + +/** Nav argument key for the one-time token carried by the emailed link. */ +const val EMAIL_CHANGE_TOKEN_ARG = "token" + +/** Where the email-change confirmation stands. */ +enum class EmailChangeStatus { + /** The link carried no usable token — nothing was sent to the server. */ + INVALID_LINK, + + /** The token is being submitted. */ + WORKING, + + /** The server accepted the token. */ + DONE, + + /** The server rejected the token (expired, already used, address taken…). */ + FAILED, +} + +/** UI state for the email-change confirm / undo screen. */ +data class EmailChangeUiState( + val action: EmailChangeAction = EmailChangeAction.VERIFY, + val status: EmailChangeStatus = EmailChangeStatus.WORKING, + /** Server-provided (or mapped) detail shown under the heading on failure. */ + val message: String? = null, +) + +/** + * Drives the screen both emailed email-change links land on. + * + * Both `POST /api/auth/verify-email-change` and `POST /api/auth/undo-email-change` + * are unauthenticated (`x-auth-type: none`, empty `security` in the OpenAPI spec), so + * the token is submitted straight away without requiring a session — the whole point + * of the undo link is that whoever reaches for it may no longer be able to sign in. + * + * A link with no token never reaches the network: it reports [EmailChangeStatus.INVALID_LINK] + * so a truncated or hand-edited URL can't be mistaken for a success. + */ +@HiltViewModel +class EmailChangeViewModel( + private val authRepository: AuthRepository, + action: EmailChangeAction, + token: String?, +) : ViewModel() { + + /** Hilt entry point: reads the action + token from the deep-link nav arguments. */ + @Inject + constructor( + authRepository: AuthRepository, + savedStateHandle: SavedStateHandle, + ) : this( + authRepository = authRepository, + action = actionFrom(savedStateHandle.get(EMAIL_CHANGE_ACTION_ARG)), + token = savedStateHandle.get(EMAIL_CHANGE_TOKEN_ARG), + ) + + private val _uiState = MutableStateFlow(EmailChangeUiState(action = action)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + val trimmed = token?.trim() + if (trimmed.isNullOrEmpty()) { + _uiState.update { it.copy(status = EmailChangeStatus.INVALID_LINK) } + } else { + submit(action, trimmed) + } + } + + private fun submit(action: EmailChangeAction, token: String) { + _uiState.update { it.copy(status = EmailChangeStatus.WORKING, message = null) } + viewModelScope.launch { + val result = when (action) { + EmailChangeAction.VERIFY -> authRepository.verifyEmailChange(token) + EmailChangeAction.UNDO -> authRepository.undoEmailChange(token) + } + when (result) { + is ApiResult.Success -> _uiState.update { it.copy(status = EmailChangeStatus.DONE) } + is ApiResult.Failure -> _uiState.update { + it.copy( + status = EmailChangeStatus.FAILED, + message = result.error.toUserMessage(), + ) + } + } + } + } + + private companion object { + /** Unknown/missing nav values default to the non-destructive half of the flow. */ + fun actionFrom(raw: String?): EmailChangeAction = + EmailChangeAction.entries.firstOrNull { it.name.equals(raw, ignoreCase = true) } + ?: EmailChangeAction.VERIFY + } +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt index accfb65..8c46ab2 100644 --- a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/data/DefaultAuthRepositoryTest.kt @@ -200,4 +200,66 @@ class DefaultAuthRepositoryTest { assertThat(result).isInstanceOf(ApiResult.Success::class.java) assertThat(server.takeRequest().path).contains("api/auth/send-verification-email") } + + // ---- email change: verify / undo --------------------------------------- + + @Test + fun `verifyEmailChange posts the token to the verify-email-change endpoint`() = runTest(dispatcher) { + enqueue(201) + + val result = repository().verifyEmailChange("change-tok") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.path).contains("api/auth/verify-email-change") + assertThat(request.body.readUtf8()).isEqualTo("{\"token\":\"change-tok\"}") + } + + @Test + fun `verifyEmailChange surfaces the server message on a conflict`() = runTest(dispatcher) { + enqueue(409, """{ "error": "That email is already in use", "code": "conflict" }""") + + val result = repository().verifyEmailChange("taken") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + val error = (result as ApiResult.Failure).error + assertThat(error).isInstanceOf(AppError.Conflict::class.java) + assertThat(error.message).isEqualTo("That email is already in use") + } + + @Test + fun `undoEmailChange posts the token to the undo-email-change endpoint`() = runTest(dispatcher) { + enqueue(201) + + val result = repository().undoEmailChange("undo-tok") + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + val request = server.takeRequest() + assertThat(request.path).contains("api/auth/undo-email-change") + assertThat(request.body.readUtf8()).isEqualTo("{\"token\":\"undo-tok\"}") + } + + @Test + fun `undoEmailChange works with no session at all`() = runTest(dispatcher) { + // The emailed undo link has to work for someone who cannot sign in any more, + // so the repository must not gate the call on a stored token. + session.clear() + enqueue(201) + + val result = repository().undoEmailChange("undo-tok") + + assertThat(session.isLoggedIn).isFalse() + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat(server.takeRequest().path).contains("api/auth/undo-email-change") + } + + @Test + fun `undoEmailChange surfaces an expired-link message`() = runTest(dispatcher) { + enqueue(400, """{ "error": "Undo link has expired", "code": "bad_request" }""") + + val result = repository().undoEmailChange("stale") + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat((result as ApiResult.Failure).error.message).isEqualTo("Undo link has expired") + } } diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLinkTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLinkTest.kt new file mode 100644 index 0000000..f80286d --- /dev/null +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/nav/EmailChangeLinkTest.kt @@ -0,0 +1,122 @@ +package com.interlinedlist.android.feature.auth.nav + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Parsing rules for the two emailed email-change links. + * + * These are the entry point for a security action reached from an email, so the + * "must not match" cases matter as much as the happy paths: a malformed or foreign + * link has to come back as null rather than as a half-populated link that the screen + * would then submit. + */ +class EmailChangeLinkTest { + + // ---- the four link shapes --------------------------------------------- + + @Test + fun `parses the https verify-email-change link`() { + val link = EmailChangeLink.parse("https://interlinedlist.com/verify-email-change?token=abc123") + + assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.VERIFY, "abc123")) + } + + @Test + fun `parses the https undo-email-change link`() { + val link = EmailChangeLink.parse("https://interlinedlist.com/undo-email-change?token=abc123") + + assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.UNDO, "abc123")) + } + + @Test + fun `parses the custom-scheme verify link`() { + val link = EmailChangeLink.parse("interlinedlist://verify-email-change?token=abc123") + + assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.VERIFY, "abc123")) + } + + @Test + fun `parses the custom-scheme undo link`() { + val link = EmailChangeLink.parse("interlinedlist://undo-email-change?token=abc123") + + assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.UNDO, "abc123")) + } + + // ---- tolerated variations --------------------------------------------- + + @Test + fun `tolerates a www host, a trailing slash, other query params and a fragment`() { + val link = EmailChangeLink.parse( + "https://WWW.InterlinedList.com/verify-email-change/?utm_source=email&token=abc123#top", + ) + + assertThat(link).isEqualTo(EmailChangeLink(EmailChangeAction.VERIFY, "abc123")) + } + + @Test + fun `percent-decodes the token`() { + val link = EmailChangeLink.parse("https://interlinedlist.com/undo-email-change?token=a%2Bb%3Dc") + + assertThat(link?.token).isEqualTo("a+b=c") + } + + @Test + fun `surrounding whitespace is ignored`() { + val link = EmailChangeLink.parse(" https://interlinedlist.com/verify-email-change?token=abc123 ") + + assertThat(link?.action).isEqualTo(EmailChangeAction.VERIFY) + } + + // ---- malformed / foreign links must not match ------------------------- + + @Test + fun `a link with no token does not parse`() { + assertThat(EmailChangeLink.parse("https://interlinedlist.com/verify-email-change")).isNull() + } + + @Test + fun `a link with a blank token does not parse`() { + assertThat(EmailChangeLink.parse("https://interlinedlist.com/undo-email-change?token=")).isNull() + assertThat(EmailChangeLink.parse("https://interlinedlist.com/undo-email-change?token=%20")).isNull() + } + + @Test + fun `a look-alike host does not parse`() { + assertThat( + EmailChangeLink.parse("https://interlinedlist.com.evil.example/verify-email-change?token=abc"), + ).isNull() + } + + @Test + fun `the plain verify-email link is not mistaken for an email change`() { + assertThat(EmailChangeLink.parse("https://interlinedlist.com/verify-email?token=abc")).isNull() + } + + @Test + fun `garbage input returns null instead of throwing`() { + assertThat(EmailChangeLink.parse("not a uri at all")).isNull() + assertThat(EmailChangeLink.parse("")).isNull() + assertThat(EmailChangeLink.parse(null)).isNull() + assertThat(EmailChangeLink.parse("verify-email-change?token=abc")).isNull() + } + + // ---- route mapping ----------------------------------------------------- + + @Test + fun `routeForEmailChangeLink maps each link onto its in-app route`() { + assertThat( + AuthRoutes.routeForEmailChangeLink("https://interlinedlist.com/verify-email-change?token=abc123"), + ).isEqualTo("auth/email-change?action=VERIFY&token=abc123") + + assertThat( + AuthRoutes.routeForEmailChangeLink("interlinedlist://undo-email-change?token=abc123"), + ).isEqualTo("auth/email-change?action=UNDO&token=abc123") + } + + @Test + fun `routeForEmailChangeLink ignores links it does not own`() { + assertThat(AuthRoutes.routeForEmailChangeLink("https://interlinedlist.com/lists/shared/xyz")).isNull() + assertThat(AuthRoutes.routeForEmailChangeLink(null)).isNull() + } +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModelTest.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModelTest.kt new file mode 100644 index 0000000..eb031be --- /dev/null +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/EmailChangeViewModelTest.kt @@ -0,0 +1,100 @@ +package com.interlinedlist.android.feature.auth.ui + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.feature.auth.nav.EmailChangeAction +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class EmailChangeViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + @Test + fun `a verify link confirms the change with the emailed token`() = runTest(dispatcher) { + val repo = FakeAuthRepository(verifyEmailChangeResult = ApiResult.Success(Unit)) + + val vm = EmailChangeViewModel(repo, EmailChangeAction.VERIFY, token = "verify-tok") + advanceUntilIdle() + + assertThat(repo.lastVerifyEmailChangeToken).isEqualTo("verify-tok") + assertThat(repo.lastUndoEmailChangeToken).isNull() + assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.DONE) + assertThat(vm.uiState.value.action).isEqualTo(EmailChangeAction.VERIFY) + } + + @Test + fun `an undo link reverts the change with the emailed token`() = runTest(dispatcher) { + val repo = FakeAuthRepository(undoEmailChangeResult = ApiResult.Success(Unit)) + + val vm = EmailChangeViewModel(repo, EmailChangeAction.UNDO, token = "undo-tok") + advanceUntilIdle() + + assertThat(repo.lastUndoEmailChangeToken).isEqualTo("undo-tok") + assertThat(repo.lastVerifyEmailChangeToken).isNull() + assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.DONE) + assertThat(vm.uiState.value.action).isEqualTo(EmailChangeAction.UNDO) + } + + @Test + fun `a rejected verify token surfaces the server's own message`() = runTest(dispatcher) { + val repo = FakeAuthRepository( + verifyEmailChangeResult = ApiResult.Failure(AppError.Conflict("That email is already in use")), + ) + + val vm = EmailChangeViewModel(repo, EmailChangeAction.VERIFY, token = "stale") + advanceUntilIdle() + + assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.FAILED) + assertThat(vm.uiState.value.message).isEqualTo("That email is already in use") + } + + @Test + fun `a rejected undo token surfaces the server's own message`() = runTest(dispatcher) { + val repo = FakeAuthRepository( + undoEmailChangeResult = ApiResult.Failure(AppError.Unknown("Undo link has expired")), + ) + + val vm = EmailChangeViewModel(repo, EmailChangeAction.UNDO, token = "stale") + advanceUntilIdle() + + assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.FAILED) + assertThat(vm.uiState.value.message).isEqualTo("Undo link has expired") + } + + @Test + fun `a link with no token never reaches the server and never reports success`() = runTest(dispatcher) { + val repo = FakeAuthRepository() + + val vm = EmailChangeViewModel(repo, EmailChangeAction.UNDO, token = null) + advanceUntilIdle() + + assertThat(repo.lastUndoEmailChangeToken).isNull() + assertThat(repo.lastVerifyEmailChangeToken).isNull() + assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.INVALID_LINK) + } + + @Test + fun `a blank token is treated as an invalid link`() = runTest(dispatcher) { + val repo = FakeAuthRepository() + + val vm = EmailChangeViewModel(repo, EmailChangeAction.VERIFY, token = " ") + advanceUntilIdle() + + assertThat(repo.lastVerifyEmailChangeToken).isNull() + assertThat(vm.uiState.value.status).isEqualTo(EmailChangeStatus.INVALID_LINK) + } +} diff --git a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt index 649a62a..1cdf351 100644 --- a/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt +++ b/feature/auth/src/test/kotlin/com/interlinedlist/android/feature/auth/ui/FakeAuthRepository.kt @@ -24,6 +24,8 @@ class FakeAuthRepository( var resetResult: ApiResult = ApiResult.Success(Unit), var verifyResult: ApiResult = ApiResult.Success(Unit), var resendResult: ApiResult = ApiResult.Success(Unit), + var verifyEmailChangeResult: ApiResult = ApiResult.Success(Unit), + var undoEmailChangeResult: ApiResult = ApiResult.Success(Unit), ) : AuthRepository { var loginCount = 0 @@ -33,6 +35,8 @@ class FakeAuthRepository( var lastReset: ResetArgs? = null var lastVerifyToken: String? = null var resendCount = 0 + var lastVerifyEmailChangeToken: String? = null + var lastUndoEmailChangeToken: String? = null data class RegisterArgs( val email: String, @@ -81,5 +85,15 @@ class FakeAuthRepository( return resendResult } + override suspend fun verifyEmailChange(token: String): ApiResult { + lastVerifyEmailChangeToken = token + return verifyEmailChangeResult + } + + override suspend fun undoEmailChange(token: String): ApiResult { + lastUndoEmailChangeToken = token + return undoEmailChangeResult + } + override suspend fun logout() = Unit } diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt index deba0d9..209e849 100644 --- a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/AccountScreensTest.kt @@ -3,10 +3,14 @@ package com.interlinedlist.android.feature.profile.ui import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.profile.domain.LoginSession +import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsScreen +import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsTestTags +import com.interlinedlist.android.feature.profile.ui.account.AccountSettingsUiState import com.interlinedlist.android.feature.profile.ui.account.SessionsScreen import com.interlinedlist.android.feature.profile.ui.account.SessionsTestTags import com.interlinedlist.android.feature.profile.ui.account.SessionsUiState @@ -67,4 +71,50 @@ class AccountScreensTest { composeRule.onNodeWithTag(SessionsTestTags.CONFIRM_REVOKE).performClick() assert(revoked == "s2") } + + // ---- pending email change --------------------------------------------- + + @Test + fun accountSettings_pendingEmailChangeShowsAddressAndResends() { + var resent = 0 + composeRule.setContent { + InterlinedListTheme { + AccountSettingsScreen( + state = AccountSettingsUiState( + username = "adron", + pendingEmail = "new@example.com", + ), + onChangeEmail = {}, + onAcknowledgeEmailChange = {}, + onResendEmailChange = { resent++ }, + onDeleteAccount = {}, + onBack = {}, + ) + } + } + + composeRule.onNodeWithTag(AccountSettingsTestTags.PENDING_EMAIL).assertIsDisplayed() + composeRule.onNodeWithText("new@example.com").assertIsDisplayed() + + composeRule.onNodeWithTag(AccountSettingsTestTags.PENDING_RESEND).performClick() + assert(resent == 1) + } + + @Test + fun accountSettings_noPendingEmailChangeHidesTheBanner() { + composeRule.setContent { + InterlinedListTheme { + AccountSettingsScreen( + state = AccountSettingsUiState(username = "adron", pendingEmail = null), + onChangeEmail = {}, + onAcknowledgeEmailChange = {}, + onResendEmailChange = {}, + onDeleteAccount = {}, + onBack = {}, + ) + } + } + + composeRule.onNodeWithTag(AccountSettingsTestTags.PENDING_EMAIL).assertDoesNotExist() + } } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt index ac30066..7b3e882 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepository.kt @@ -298,6 +298,13 @@ class DefaultProfileRepository @Inject constructor( safeApiCall(json) { api.requestEmailChange(ChangeEmailRequest(newEmail)) } } + override suspend fun getPendingEmailChange(): ApiResult = + withContext(dispatchers.io) { + safeApiCall(json) { + api.getCurrentUser().userOrSelf?.pendingEmail?.takeIf { it.isNotBlank() } + } + } + override suspend fun deleteAccount(username: String, email: String): ApiResult = withContext(dispatchers.io) { safeApiCall(json) { api.deleteAccount(DeleteAccountRequest(username = username, email = email)) } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt index e328136..1525aee 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/ProfileRepository.kt @@ -144,6 +144,14 @@ interface ProfileRepository { /** Requests an email change to [newEmail] via `POST /api/user/change-email/request`. */ suspend fun requestEmailChange(newEmail: String): ApiResult + /** + * The address an email change is currently waiting on, read from `pendingEmail` + * on `GET /api/user`, or null when no change is in flight. Deliberately not + * cached: it is the kind of state that must never be shown stale, and it clears + * the moment the change is confirmed (or undone) from the emailed link. + */ + suspend fun getPendingEmailChange(): ApiResult + /** * Deletes the current user's account via `POST /api/user/delete`, confirming with * the account's [username] and [email]. On success the caller signs the user out. diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt index 0365fa6..2374884 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileResponses.kt @@ -17,6 +17,7 @@ data class ProfileResponse( val avatar: String? = null, val bio: String? = null, val customerStatus: String? = null, + val pendingEmail: String? = null, ) { /** The user payload, whether wrapped under `user` or inlined at the top level. */ val userOrSelf: ProfileUserDto? @@ -29,6 +30,7 @@ data class ProfileResponse( avatar = avatar, bio = bio, customerStatus = customerStatus, + pendingEmail = pendingEmail, ) } } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt index 5efb7f2..acc130d 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/data/remote/dto/ProfileUserDto.kt @@ -17,6 +17,12 @@ data class ProfileUserDto( val avatar: String? = null, val bio: String? = null, val customerStatus: String? = null, + /** + * The address a requested email change is waiting on, or null when no change is + * in flight. Present on `GET /api/user` for the signed-in user only (confirmed + * live); other users' profiles omit it. + */ + val pendingEmail: String? = null, // --- Preference fields (present on `GET /api/user` for the signed-in user // only; other users' public profiles omit them, hence all-nullable). --- val theme: String? = null, diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt index 74946bd..7c38ecb 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsScreen.kt @@ -15,6 +15,8 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -48,6 +50,9 @@ object AccountSettingsTestTags { const val EMAIL_FIELD = "accountSettingsEmailField" const val CHANGE_EMAIL = "accountSettingsChangeEmail" const val EMAIL_REQUESTED = "accountSettingsEmailRequested" + const val PENDING_EMAIL = "accountSettingsPendingEmail" + const val PENDING_RESEND = "accountSettingsPendingResend" + const val PENDING_RESENT = "accountSettingsPendingResent" const val ERROR = "accountSettingsError" const val DELETE_ACCOUNT = "accountSettingsDeleteAccount" const val DELETE_DIALOG = "accountSettingsDeleteDialog" @@ -77,6 +82,7 @@ fun AccountSettingsRoute( state = state, onChangeEmail = viewModel::requestEmailChange, onAcknowledgeEmailChange = viewModel::acknowledgeEmailChange, + onResendEmailChange = viewModel::resendEmailChange, onDeleteAccount = viewModel::deleteAccount, onBack = onBack, modifier = modifier, @@ -105,6 +111,7 @@ fun AccountSettingsScreen( state: AccountSettingsUiState, onChangeEmail: (String) -> Unit, onAcknowledgeEmailChange: () -> Unit, + onResendEmailChange: () -> Unit, onDeleteAccount: (String) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -140,6 +147,16 @@ fun AccountSettingsScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (state.pendingEmail != null) { + Spacer(Modifier.height(12.dp)) + PendingEmailChangeCard( + pendingEmail = state.pendingEmail, + isResending = state.isResendingEmailChange, + wasResent = state.emailChangeResent, + onResend = onResendEmailChange, + ) + } + Spacer(Modifier.height(12.dp)) OutlinedTextField( value = newEmail, @@ -227,6 +244,69 @@ fun AccountSettingsScreen( } } +/** + * Shows the address an email change is waiting on, with the two things the API + * actually supports from here: re-sending the confirmation email, and the plain + * statement of how to stop the change (the undo link in the message sent to the + * current address — `POST /api/auth/undo-email-change`). The server exposes no + * endpoint that cancels a pending change directly, so no button pretends to. + */ +@Composable +private fun PendingEmailChangeCard( + pendingEmail: String, + isResending: Boolean, + wasResent: Boolean, + onResend: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + modifier = Modifier + .fillMaxWidth() + .testTag(AccountSettingsTestTags.PENDING_EMAIL), + ) { + Column(Modifier.padding(16.dp)) { + Text("Waiting for confirmation", style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.height(4.dp)) + Text( + text = pendingEmail, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Your account keeps its current email until you open the link we " + + "sent to this address. To stop the change, use the “undo” link in the " + + "email sent to your current address.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = onResend, + enabled = !isResending, + modifier = Modifier.testTag(AccountSettingsTestTags.PENDING_RESEND), + ) { + if (isResending) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text("Resend confirmation email") + } + } + if (wasResent) { + Spacer(Modifier.height(8.dp)) + Text( + text = "Sent again — check that inbox.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.testTag(AccountSettingsTestTags.PENDING_RESENT), + ) + } + } + } +} + /** * The type-to-confirm delete guard: the user must re-type their exact username and enter * their email before the destructive confirm button enables. @@ -301,9 +381,13 @@ private fun DeleteAccountDialog( private fun AccountSettingsScreenPreview() { InterlinedListTheme { AccountSettingsScreen( - state = AccountSettingsUiState(username = "adron"), + state = AccountSettingsUiState( + username = "adron", + pendingEmail = "new@example.com", + ), onChangeEmail = {}, onAcknowledgeEmailChange = {}, + onResendEmailChange = {}, onDeleteAccount = {}, onBack = {}, ) diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt index 8f81d9d..7b7f008 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/account/AccountSettingsViewModel.kt @@ -20,7 +20,9 @@ import javax.inject.Inject * UI state for the Account settings screen (change email + delete account). * * [username] is seeded from the current user's cache and is the value the - * type-to-confirm delete guard checks against. + * type-to-confirm delete guard checks against. [pendingEmail] mirrors the server's + * `pendingEmail` field: non-null while an email change is awaiting confirmation from + * the link mailed to that address, and null again once it is confirmed or undone. */ data class AccountSettingsUiState( val username: String = "", @@ -28,6 +30,11 @@ data class AccountSettingsUiState( val isDeletingAccount: Boolean = false, // A one-shot confirmation to show after a successful email-change request. val emailChangeRequested: Boolean = false, + /** The address an email change is waiting on, or null when none is in flight. */ + val pendingEmail: String? = null, + val isResendingEmailChange: Boolean = false, + /** A one-shot confirmation to show after the verification email is re-sent. */ + val emailChangeResent: Boolean = false, val errorMessage: String? = null, ) @@ -43,6 +50,10 @@ sealed interface AccountSettingsEffect { * `POST /api/user/delete`. On a successful delete it emits [AccountSettingsEffect.SignedOut] * so the app can clear the session and navigate away (the profile module does not own * session state). + * + * The pending-change banner is re-read from the server on every entry rather than + * cached, so it disappears as soon as the change is confirmed (or undone) from the + * emailed link — which happens outside this screen, and possibly on another device. */ @HiltViewModel class AccountSettingsViewModel @Inject constructor( @@ -57,6 +68,22 @@ class AccountSettingsViewModel @Inject constructor( init { seedFromCache() + refreshPendingEmailChange() + } + + /** + * Re-reads `pendingEmail` from `GET /api/user`. Called on entry and after a + * request/resend so the banner reflects the server, not a local guess. + */ + fun refreshPendingEmailChange() { + viewModelScope.launch { + when (val result = repository.getPendingEmailChange()) { + is ApiResult.Success -> _uiState.update { it.copy(pendingEmail = result.data) } + // A failed read is not worth an error banner on a settings screen: + // leave whatever is on screen alone and try again next entry. + is ApiResult.Failure -> Unit + } + } } /** Seeds [AccountSettingsUiState.username] from the cached current user. */ @@ -77,8 +104,15 @@ class AccountSettingsViewModel @Inject constructor( _uiState.update { it.copy(isChangingEmail = true, emailChangeRequested = false, errorMessage = null) } viewModelScope.launch { when (val result = repository.requestEmailChange(email)) { - is ApiResult.Success -> _uiState.update { - it.copy(isChangingEmail = false, emailChangeRequested = true) + is ApiResult.Success -> { + _uiState.update { + it.copy( + isChangingEmail = false, + emailChangeRequested = true, + pendingEmail = email, + ) + } + refreshPendingEmailChange() } is ApiResult.Failure -> _uiState.update { it.copy(isChangingEmail = false, errorMessage = result.error.toUserMessage()) @@ -87,6 +121,31 @@ class AccountSettingsViewModel @Inject constructor( } } + /** + * Re-sends the confirmation email for the change already in flight, by re-issuing + * the same `POST /api/user/change-email/request` for the pending address. + */ + fun resendEmailChange() { + val pending = _uiState.value.pendingEmail + if (pending.isNullOrBlank() || _uiState.value.isResendingEmailChange) return + _uiState.update { + it.copy(isResendingEmailChange = true, emailChangeResent = false, errorMessage = null) + } + viewModelScope.launch { + when (val result = repository.requestEmailChange(pending)) { + is ApiResult.Success -> _uiState.update { + it.copy(isResendingEmailChange = false, emailChangeResent = true) + } + is ApiResult.Failure -> _uiState.update { + it.copy( + isResendingEmailChange = false, + errorMessage = result.error.toUserMessage(), + ) + } + } + } + } + /** * Deletes the account after the type-to-confirm guard passed. Requires the account's * [email] (the server verifies both username and email). Emits @@ -112,4 +171,6 @@ class AccountSettingsViewModel @Inject constructor( fun clearError() = _uiState.update { it.copy(errorMessage = null) } fun acknowledgeEmailChange() = _uiState.update { it.copy(emailChangeRequested = false) } + + fun acknowledgeEmailChangeResent() = _uiState.update { it.copy(emailChangeResent = false) } } diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt index f274df5..087dd43 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultProfileRepositoryTest.kt @@ -820,6 +820,36 @@ class DefaultProfileRepositoryTest { assertThat(recorded.body.readUtf8()).contains("\"newEmail\":\"new@example.com\"") } + @Test + fun `getPendingEmailChange reads pendingEmail off the current user`() = runTest(testDispatcher) { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "user": { "id": "u1", "username": "adron", "email": "old@example.com", + "pendingEmail": "new@example.com" } }""", + ), + ) + + val result = repository.getPendingEmailChange() + + assertThat(result).isInstanceOf(ApiResult.Success::class.java) + assertThat((result as ApiResult.Success).data).isEqualTo("new@example.com") + assertThat(server.takeRequest().path).isEqualTo("/api/user") + } + + @Test + fun `getPendingEmailChange is null when no change is in flight`() = runTest(testDispatcher) { + // The live API sends an explicit null rather than omitting the key. + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{ "user": { "id": "u1", "username": "adron", "pendingEmail": null } }""", + ), + ) + + val result = repository.getPendingEmailChange() + + assertThat((result as ApiResult.Success).data).isNull() + } + @Test fun `requestEmailChange maps a 400 to a failure`() = runTest(testDispatcher) { server.enqueue(MockResponse().setResponseCode(400).setBody("""{ "error": "Email already in use." }""")) diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt index 234f77b..bec54d3 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/AccountSettingsViewModelTest.kt @@ -69,6 +69,102 @@ class AccountSettingsViewModelTest { assertThat(vm.uiState.value.errorMessage).isNotNull() } + // ---- pending email change --------------------------------------------- + + @Test + fun `the pending change renders from the server's pendingEmail`() = runTest(dispatcher) { + repo.pendingEmailChangeResult = ApiResult.Success("new@example.com") + + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.pendingEmail).isEqualTo("new@example.com") + } + + @Test + fun `no pending change means no banner`() = runTest(dispatcher) { + repo.pendingEmailChangeResult = ApiResult.Success(null) + + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + assertThat(vm.uiState.value.pendingEmail).isNull() + } + + @Test + fun `the pending change clears once the server reports it verified`() = runTest(dispatcher) { + // First read: still awaiting confirmation. Second read (after the user opened + // the emailed link): the server has cleared pendingEmail. + repo.pendingEmailChangeResults = ArrayDeque( + listOf(ApiResult.Success("new@example.com"), ApiResult.Success(null)), + ) + + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + assertThat(vm.uiState.value.pendingEmail).isEqualTo("new@example.com") + + vm.refreshPendingEmailChange() + advanceUntilIdle() + + assertThat(vm.uiState.value.pendingEmail).isNull() + } + + @Test + fun `a successful request puts the screen straight into the pending state`() = runTest(dispatcher) { + repo.pendingEmailChangeResult = ApiResult.Success(null) + repo.requestEmailChangeResult = ApiResult.Success(Unit) + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + repo.pendingEmailChangeResult = ApiResult.Success("new@example.com") + vm.requestEmailChange("new@example.com") + advanceUntilIdle() + + assertThat(vm.uiState.value.pendingEmail).isEqualTo("new@example.com") + } + + @Test + fun `resend re-requests the change for the pending address`() = runTest(dispatcher) { + repo.pendingEmailChangeResult = ApiResult.Success("new@example.com") + repo.requestEmailChangeResult = ApiResult.Success(Unit) + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + vm.resendEmailChange() + advanceUntilIdle() + + assertThat(repo.requestedEmails).containsExactly("new@example.com") + assertThat(vm.uiState.value.emailChangeResent).isTrue() + assertThat(vm.uiState.value.isResendingEmailChange).isFalse() + } + + @Test + fun `resend surfaces the server's message on failure`() = runTest(dispatcher) { + repo.pendingEmailChangeResult = ApiResult.Success("new@example.com") + repo.requestEmailChangeResult = + ApiResult.Failure(AppError.Conflict("That email is already in use")) + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + vm.resendEmailChange() + advanceUntilIdle() + + assertThat(vm.uiState.value.emailChangeResent).isFalse() + assertThat(vm.uiState.value.errorMessage).isEqualTo("That email is already in use") + } + + @Test + fun `resend does nothing when no change is pending`() = runTest(dispatcher) { + repo.pendingEmailChangeResult = ApiResult.Success(null) + val vm = AccountSettingsViewModel(repo) + advanceUntilIdle() + + vm.resendEmailChange() + advanceUntilIdle() + + assertThat(repo.requestedEmails).isEmpty() + } + @Test fun `deleteAccount confirms with the seeded username and emits a signed-out effect`() = runTest(dispatcher) { repo.currentUserFlow.value = testUser(username = "adron") diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt index ca04e5d..cd3ef35 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeProfileRepository.kt @@ -219,6 +219,8 @@ class FakeProfileRepository : ProfileRepository { var identitiesResult: ApiResult> = ApiResult.Success(emptyList()) var unlinkIdentityResult: ApiResult = ApiResult.Success(Unit) var requestEmailChangeResult: ApiResult = ApiResult.Success(Unit) + var pendingEmailChangeResults: ArrayDeque> = ArrayDeque() + var pendingEmailChangeResult: ApiResult = ApiResult.Success(null) var deleteAccountResult: ApiResult = ApiResult.Success(Unit) var sessionsCount = 0 @@ -226,6 +228,8 @@ class FakeProfileRepository : ProfileRepository { var identitiesCount = 0 var unlinkedProvider: String? = null var requestedEmail: String? = null + var requestedEmails: MutableList = mutableListOf() + var pendingEmailChangeCount = 0 var deleteAccountArgs: Pair? = null override suspend fun getSessions(): ApiResult> { @@ -250,9 +254,19 @@ class FakeProfileRepository : ProfileRepository { override suspend fun requestEmailChange(newEmail: String): ApiResult { requestedEmail = newEmail + requestedEmails += newEmail return requestEmailChangeResult } + /** + * Returns the next queued result, falling back to [pendingEmailChangeResult] so + * a test can either script a sequence (pending → cleared) or pin one value. + */ + override suspend fun getPendingEmailChange(): ApiResult { + pendingEmailChangeCount++ + return pendingEmailChangeResults.removeFirstOrNull() ?: pendingEmailChangeResult + } + override suspend fun deleteAccount(username: String, email: String): ApiResult { deleteAccountArgs = username to email return deleteAccountResult