feat: add drawer logout confirmation and session clear flow
This commit is contained in:
@@ -298,6 +298,64 @@ class BoardsFlowTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutConfirmationClearsSessionAndReturnsToLogin() {
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
|
||||
val apiKeyStore = InMemoryApiKeyStore("api")
|
||||
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||
MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore }
|
||||
MainActivity.dependencies.apiClientFactory = {
|
||||
FakeBoardsApiClient(
|
||||
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||
templates = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
|
||||
|
||||
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
|
||||
onView(withId(R.id.drawerLogoutButton)).perform(click())
|
||||
onView(withText(R.string.drawer_logout)).inRoot(isDialog()).perform(click())
|
||||
|
||||
Intents.intended(hasComponent(MainActivity::class.java.name))
|
||||
assertEquals(null, sessionStore.getBaseUrl())
|
||||
assertEquals(null, sessionStore.getWorkspaceId())
|
||||
assertEquals(null, sessionStore.getDraftBaseUrl())
|
||||
assertEquals(null, sessionStore.getDraftApiKey())
|
||||
assertEquals(listOf("https://kan.bn/"), apiKeyStore.invalidatedBaseUrls)
|
||||
scenario.onActivity { activity ->
|
||||
assertTrue(activity.isFinishing)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logoutConfirmationCancelKeepsUserOnBoards() {
|
||||
val sessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1")
|
||||
val apiKeyStore = InMemoryApiKeyStore("api")
|
||||
MainActivity.dependencies.sessionStoreFactory = { sessionStore }
|
||||
MainActivity.dependencies.apiKeyStoreFactory = { apiKeyStore }
|
||||
MainActivity.dependencies.apiClientFactory = {
|
||||
FakeBoardsApiClient(
|
||||
boards = mutableListOf(BoardSummary("1", "Alpha")),
|
||||
templates = emptyList(),
|
||||
)
|
||||
}
|
||||
|
||||
val scenario = ActivityScenario.launch(BoardsActivity::class.java)
|
||||
|
||||
onView(withId(R.id.boardsDrawerLayout)).perform(DrawerActions.open())
|
||||
onView(withId(R.id.drawerLogoutButton)).perform(click())
|
||||
onView(withText(R.string.cancel)).inRoot(isDialog()).perform(click())
|
||||
|
||||
onView(withText("Alpha")).check(matches(isDisplayed()))
|
||||
assertEquals("https://kan.bn/", sessionStore.getBaseUrl())
|
||||
assertEquals("ws-1", sessionStore.getWorkspaceId())
|
||||
assertTrue(apiKeyStore.invalidatedBaseUrls.isEmpty())
|
||||
scenario.onActivity { activity ->
|
||||
assertFalse(activity.isFinishing)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsDialogOpensFromDrawerAndShowsPreferences() {
|
||||
MainActivity.dependencies.apiClientFactory = {
|
||||
@@ -536,6 +594,9 @@ class BoardsFlowTest {
|
||||
private var baseUrl: String? = null,
|
||||
private var workspaceId: String? = "ws-1",
|
||||
) : SessionStore {
|
||||
private var draftBaseUrl: String? = baseUrl
|
||||
private var draftApiKey: String? = null
|
||||
|
||||
override fun getBaseUrl(): String? = baseUrl
|
||||
|
||||
override fun saveBaseUrl(url: String) {
|
||||
@@ -555,11 +616,29 @@ class BoardsFlowTest {
|
||||
override fun clearWorkspaceId() {
|
||||
workspaceId = null
|
||||
}
|
||||
|
||||
override fun initializeDraftsFromCommitted() {
|
||||
draftBaseUrl = baseUrl
|
||||
}
|
||||
|
||||
override fun getDraftBaseUrl(): String? = draftBaseUrl
|
||||
|
||||
override fun saveDraftBaseUrl(url: String) {
|
||||
draftBaseUrl = url
|
||||
}
|
||||
|
||||
override fun getDraftApiKey(): String? = draftApiKey
|
||||
|
||||
override fun saveDraftApiKey(apiKey: String) {
|
||||
draftApiKey = apiKey
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemoryApiKeyStore(
|
||||
private var key: String?,
|
||||
) : ApiKeyStore {
|
||||
val invalidatedBaseUrls = mutableListOf<String>()
|
||||
|
||||
override suspend fun saveApiKey(baseUrl: String, apiKey: String): Result<Unit> {
|
||||
key = apiKey
|
||||
return Result.success(Unit)
|
||||
@@ -568,6 +647,7 @@ class BoardsFlowTest {
|
||||
override suspend fun getApiKey(baseUrl: String): Result<String?> = Result.success(key)
|
||||
|
||||
override suspend fun invalidateApiKey(baseUrl: String): Result<Unit> {
|
||||
invalidatedBaseUrls += baseUrl
|
||||
key = null
|
||||
return Result.success(Unit)
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ class BoardsActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
drawerLogoutButton.setOnClickListener {
|
||||
forceSignOutToLogin()
|
||||
showLogoutConfirmation()
|
||||
}
|
||||
|
||||
drawerLayout.addDrawerListener(
|
||||
@@ -300,7 +300,12 @@ class BoardsActivity : AppCompatActivity() {
|
||||
apiKeyStore.invalidateApiKey(baseUrl)
|
||||
}
|
||||
}
|
||||
sessionStore.clearBaseUrl()
|
||||
sessionStore.clearApiKey()
|
||||
sessionStore.clearWorkspaceId()
|
||||
sessionStore.saveDraftBaseUrl("")
|
||||
sessionStore.saveDraftApiKey("")
|
||||
viewModel.clearSessionUiState()
|
||||
val intent = Intent(this@BoardsActivity, MainActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||
startActivity(intent)
|
||||
@@ -308,6 +313,16 @@ class BoardsActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showLogoutConfirmation() {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setMessage(getString(R.string.drawer_logout_confirmation_message))
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.drawer_logout) { _, _ ->
|
||||
forceSignOutToLogin()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showCreateBoardDialog() {
|
||||
val dialogView = LayoutInflater.from(this).inflate(R.layout.dialog_create_board, null)
|
||||
val nameLayout: TextInputLayout = dialogView.findViewById(R.id.createBoardNameLayout)
|
||||
|
||||
@@ -13,6 +13,10 @@ class BoardsRepository(
|
||||
private val apiClient: KanbnApiClient,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
fun clearSessionCaches() {
|
||||
// No-op: this repository currently keeps no in-memory session cache.
|
||||
}
|
||||
|
||||
suspend fun loadDrawerData(): DrawerDataResult {
|
||||
val session = when (val sessionResult = authSession()) {
|
||||
is BoardsApiResult.Success -> sessionResult.value
|
||||
|
||||
@@ -129,6 +129,13 @@ class BoardsViewModel(
|
||||
fetchBoards(initial = false, refresh = true)
|
||||
}
|
||||
|
||||
fun clearSessionUiState() {
|
||||
repository.clearSessionCaches()
|
||||
lastDrawerLoadAtMillis = null
|
||||
hadDrawerLoadFailureSinceLastSuccess = false
|
||||
_uiState.value = BoardsUiState()
|
||||
}
|
||||
|
||||
fun onSettingsApplied(credentialsChanged: Boolean) {
|
||||
if (!credentialsChanged) {
|
||||
return
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
<string name="drawer_settings">Settings</string>
|
||||
<string name="drawer_workspaces">Workspaces</string>
|
||||
<string name="drawer_logout">Log out</string>
|
||||
<string name="drawer_logout_confirmation_message">Log out and clear this device session?</string>
|
||||
<string name="drawer_retry">Retry</string>
|
||||
<string name="drawer_profile_unavailable">Profile unavailable</string>
|
||||
<string name="drawer_workspaces_unavailable">Workspaces unavailable</string>
|
||||
|
||||
@@ -478,6 +478,33 @@ class BoardsViewModelTest {
|
||||
assertTrue(api.listBoardsCalls >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearSessionUiStateResetsBoardsStateToDefaults() = runTest {
|
||||
val api = FakeBoardsApiClient().apply {
|
||||
listBoardsResult = BoardsApiResult.Success(listOf(BoardSummary("1", "Alpha")))
|
||||
usersMeResult = BoardsApiResult.Success(DrawerProfile(displayName = "Alice", email = "alice@example.com"))
|
||||
workspacesResult = BoardsApiResult.Success(listOf(WorkspaceSummary("ws-1", "Main")))
|
||||
}
|
||||
val viewModel = newViewModel(api)
|
||||
|
||||
viewModel.loadBoards()
|
||||
viewModel.loadDrawerData()
|
||||
advanceUntilIdle()
|
||||
assertEquals(1, viewModel.uiState.value.boards.size)
|
||||
assertEquals("Alice", viewModel.uiState.value.drawer.profile?.displayName)
|
||||
|
||||
viewModel.clearSessionUiState()
|
||||
|
||||
val state = viewModel.uiState.value
|
||||
assertTrue(state.isInitialLoading)
|
||||
assertFalse(state.isRefreshing)
|
||||
assertFalse(state.isMutating)
|
||||
assertTrue(state.boards.isEmpty())
|
||||
assertTrue(state.templates.isEmpty())
|
||||
assertFalse(state.isTemplatesLoading)
|
||||
assertEquals(BoardsDrawerState(), state.drawer)
|
||||
}
|
||||
|
||||
private fun newViewModel(
|
||||
apiClient: FakeBoardsApiClient,
|
||||
sessionStore: InMemorySessionStore = InMemorySessionStore("https://kan.bn/", workspaceId = "ws-1"),
|
||||
|
||||
Reference in New Issue
Block a user