From c149bf2c8205c7fe781967f60ed3be3b8915f374 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:16:11 -0700 Subject: [PATCH] feat(settings): let an account be made private from Permissions Add the isPrivateAccount toggle to the Settings screen, in a Permissions group that mirrors where the web files it: its Settings page lists "Account visibility" under Permissions, and /help/account and /help/people both tell users to enable Private Account in "Settings, then Permissions". Because this setting changes who can see the user's content, the consequence is spelled out next to the switch in the help centre's own words (/help/people, "Private accounts"): new follows become follow requests to approve or reject, existing followers are unaffected, non-approved followers cannot see private messages, and going public again does not re-expose content to unapproved followers. The note also points at Account, then Follow requests, which is the existing route to the approve/reject screen. The write follows the established partial-PATCH pattern: only isPrivateAccount goes on the body, it applies optimistically and rolls back when the server refuses, and an absent value reads as public so the switch never claims a privacy guarantee the server is not making. Tests: SettingsPrivateAccountTest covers the single-field PATCH, optimistic apply, rollback on failure, the no-op when unchanged and the state after a refresh; DefaultSettingsRepositoryTest asserts the wire body carries isPrivateAccount alone as a JSON boolean; SettingsScreenTest asserts the switch reflects the stored state (including after a refresh) and that the explanation is shown. Closes #34 --- .../feature/profile/ui/SettingsScreenTest.kt | 88 ++++++++ .../feature/profile/domain/SettingsBounds.kt | 13 ++ .../profile/ui/settings/SettingsScreen.kt | 67 ++++++ .../profile/ui/settings/SettingsViewModel.kt | 20 ++ .../data/DefaultSettingsRepositoryTest.kt | 48 ++++- .../profile/ui/FakeSettingsRepository.kt | 1 + .../profile/ui/SettingsPrivateAccountTest.kt | 196 ++++++++++++++++++ 7 files changed, 432 insertions(+), 1 deletion(-) create mode 100644 feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsPrivateAccountTest.kt diff --git a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt index d540bcc..90bab43 100644 --- a/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt +++ b/feature/profile/src/androidTest/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsScreenTest.kt @@ -44,6 +44,7 @@ class SettingsScreenTest { onSetMaxMessageLength: (Int) -> Unit = {}, onToggleDefaultPubliclyVisible: (Boolean) -> Unit = {}, onToggleShowAdvancedPostSettings: (Boolean) -> Unit = {}, + onTogglePrivateAccount: (Boolean) -> Unit = {}, onRetry: () -> Unit = {}, onDismissError: () -> Unit = {}, ) { @@ -59,6 +60,7 @@ class SettingsScreenTest { onSetMaxMessageLength = onSetMaxMessageLength, onToggleDefaultPubliclyVisible = onToggleDefaultPubliclyVisible, onToggleShowAdvancedPostSettings = onToggleShowAdvancedPostSettings, + onTogglePrivateAccount = onTogglePrivateAccount, onDismissError = onDismissError, ) } @@ -298,6 +300,7 @@ class SettingsScreenTest { onSetMaxMessageLength = { settings = settings.copy(maxMessageLength = it) }, onToggleDefaultPubliclyVisible = {}, onToggleShowAdvancedPostSettings = {}, + onTogglePrivateAccount = {}, onDismissError = {}, ) } @@ -311,4 +314,89 @@ class SettingsScreenTest { composeRule.onNodeWithTag(SettingsTestTags.MAX_MESSAGE_LENGTH).assertTextEquals("666") } + + // --- Private account (issue #34) ----------------------------------------- + + @Test + fun permissions_showsAPublicAccountAsOff() { + setContent(SettingsUiState(settings = UserSettings(isPrivateAccount = false))) + + composeRule.onNodeWithTag(SettingsTestTags.PERMISSIONS).assertIsDisplayed() + composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOff() + } + + @Test + fun permissions_showsAPrivateAccountAsOn() { + setContent(SettingsUiState(settings = UserSettings(isPrivateAccount = true))) + + composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOn() + } + + @Test + fun permissions_treatsAnAbsentValueAsPublic() { + setContent(SettingsUiState(settings = UserSettings(isPrivateAccount = null))) + + composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOff() + } + + @Test + fun permissions_explainsTheConsequenceOfGoingPrivate() { + setContent(SettingsUiState(settings = UserSettings(isPrivateAccount = false))) + + // The wording is the help centre's (/help/people, "Private accounts"): new + // follows become requests, and existing followers keep their access. + composeRule.onNodeWithText( + "New followers must send a follow request that you approve or reject. " + + "Existing followers are not affected \u2014 they remain followers " + + "unless you remove them.", + ).assertIsDisplayed() + composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT_NOTE).assertIsDisplayed() + } + + @Test + fun togglingPrivateAccount_reportsTheNewValue() { + var toggled: Boolean? = null + setContent( + state = SettingsUiState(settings = UserSettings(isPrivateAccount = false)), + onTogglePrivateAccount = { toggled = it }, + ) + + composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).performClick() + + assert(toggled == true) + } + + @Test + fun privateAccount_switchFollowsTheStateAfterARefresh() { + // A refresh (or a rolled-back save) re-renders the screen with the value the + // server last reported; the switch must follow it rather than keep the value + // the user tapped. + var settings by mutableStateOf(UserSettings(isPrivateAccount = false)) + composeRule.setContent { + InterlinedListTheme { + SettingsScreen( + state = SettingsUiState(settings = settings), + onBack = {}, + onRetry = {}, + onSelectViewingPreference = {}, + onToggleShowPreviews = {}, + onSetMessagesPerPage = {}, + onSetMaxMessageLength = {}, + onToggleDefaultPubliclyVisible = {}, + onToggleShowAdvancedPostSettings = {}, + // Optimistic apply; the refresh below stands in for the server's answer. + onTogglePrivateAccount = { settings = settings.copy(isPrivateAccount = it) }, + onDismissError = {}, + ) + } + } + + composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOff().performClick() + composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOn() + + // The server says the account is public after all. + settings = settings.copy(isPrivateAccount = false) + + composeRule.onNodeWithTag(SettingsTestTags.PRIVATE_ACCOUNT).assertIsOff() + } } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/SettingsBounds.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/SettingsBounds.kt index 6a3be57..baeb714 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/SettingsBounds.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/domain/SettingsBounds.kt @@ -41,6 +41,15 @@ object SettingsBounds { /** The composer's gear options stay hidden unless the account opts in. */ const val DEFAULT_SHOW_ADVANCED_POST_SETTINGS: Boolean = false + + /** + * Accounts are public unless the owner says otherwise — the help centre frames + * private as something you "enable" (`/help/account`, "Private account"). An + * absent `isPrivateAccount` must therefore read as public, never as private: + * showing the switch on for an account that is not actually private would + * promise a privacy guarantee the server is not making. + */ + const val DEFAULT_PRIVATE_ACCOUNT: Boolean = false } /** @@ -62,3 +71,7 @@ val UserSettings.defaultPubliclyVisibleOrDefault: Boolean /** Whether the composer's advanced options show, falling back to the server default. */ val UserSettings.showAdvancedPostSettingsOrDefault: Boolean get() = showAdvancedPostSettings ?: SettingsBounds.DEFAULT_SHOW_ADVANCED_POST_SETTINGS + +/** Whether the account is private, falling back to public when the API omits it. */ +val UserSettings.isPrivateAccountOrDefault: Boolean + get() = isPrivateAccount ?: SettingsBounds.DEFAULT_PRIVATE_ACCOUNT diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt index e8059bd..bb9dae6 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsScreen.kt @@ -38,6 +38,7 @@ import com.interlinedlist.android.feature.profile.domain.SettingsBounds import com.interlinedlist.android.feature.profile.domain.UserSettings import com.interlinedlist.android.feature.profile.domain.ViewingPreference import com.interlinedlist.android.feature.profile.domain.defaultPubliclyVisibleOrDefault +import com.interlinedlist.android.feature.profile.domain.isPrivateAccountOrDefault import com.interlinedlist.android.feature.profile.domain.maxMessageLengthOrDefault import com.interlinedlist.android.feature.profile.domain.messagesPerPageOrDefault import com.interlinedlist.android.feature.profile.domain.showAdvancedPostSettingsOrDefault @@ -52,11 +53,14 @@ object SettingsTestTags { const val PROFILE = "settingsGroupProfile" const val VIEW_PREFERENCES = "settingsGroupViewPreferences" const val MESSAGE_SETTINGS = "settingsGroupMessageSettings" + const val PERMISSIONS = "settingsGroupPermissions" const val SHOW_PREVIEWS = "settingsShowPreviews" const val MAX_MESSAGE_LENGTH = "settingsMaxMessageLength" const val MESSAGES_PER_PAGE = "settingsMessagesPerPage" const val DEFAULT_PUBLICLY_VISIBLE = "settingsDefaultPubliclyVisible" const val SHOW_ADVANCED_POST_SETTINGS = "settingsShowAdvancedPostSettings" + const val PRIVATE_ACCOUNT = "settingsPrivateAccount" + const val PRIVATE_ACCOUNT_NOTE = "settingsPrivateAccountNote" /** Tag for one feed-filter option, keyed on its wire value. */ fun viewingPreference(option: ViewingPreference): String = @@ -103,6 +107,7 @@ fun SettingsRoute( onSetMaxMessageLength = viewModel::setMaxMessageLength, onToggleDefaultPubliclyVisible = viewModel::setDefaultPubliclyVisible, onToggleShowAdvancedPostSettings = viewModel::setShowAdvancedPostSettings, + onTogglePrivateAccount = viewModel::setPrivateAccount, onDismissError = viewModel::dismissError, modifier = modifier, ) @@ -121,6 +126,7 @@ fun SettingsScreen( onSetMaxMessageLength: (Int) -> Unit, onToggleDefaultPubliclyVisible: (Boolean) -> Unit, onToggleShowAdvancedPostSettings: (Boolean) -> Unit, + onTogglePrivateAccount: (Boolean) -> Unit, onDismissError: () -> Unit, modifier: Modifier = Modifier, ) { @@ -163,6 +169,10 @@ fun SettingsScreen( onToggleDefaultPubliclyVisible = onToggleDefaultPubliclyVisible, onToggleShowAdvancedPostSettings = onToggleShowAdvancedPostSettings, ) + PermissionsGroup( + settings = settings, + onTogglePrivateAccount = onTogglePrivateAccount, + ) Spacer(Modifier.height(24.dp)) } @@ -307,6 +317,61 @@ private fun MessageSettingsGroup( } } +/** + * "Permissions": who may follow the account and therefore see its content. + * + * The web files the private-account switch here — its Settings page lists "Account + * visibility: Make your account private; new followers will need to request to follow + * you and you must approve them" under **Permissions** (`/help/settings`), and both + * `/help/account` and `/help/people` tell users to enable **Private Account** in + * "Settings, then Permissions" — so this group mirrors that placement and name. + * + * The consequence is spelled out next to the switch rather than left to be discovered, + * because flipping it changes who can see the user's content. The wording is the help + * centre's own (`/help/people`, "Private accounts"): "New followers must send a follow + * request that you approve or reject", "Existing followers are not affected; they + * remain followers unless you remove them", "Users who are not approved followers + * cannot see your private messages", and "Switching back to a public account does not + * automatically re-expose previously hidden content to unapproved followers." + * + * Requests themselves are approved or rejected on the Follow requests screen, which + * the Account tab already links to, so the note points there instead of adding a + * second route to the same place. + */ +@Composable +private fun PermissionsGroup( + settings: UserSettings, + onTogglePrivateAccount: (Boolean) -> Unit, +) { + SettingsGroup( + title = "Permissions", + description = "Who can follow you, and who can see what you post.", + modifier = Modifier.testTag(SettingsTestTags.PERMISSIONS), + ) { + SettingsSwitchRow( + label = "Private account", + description = "New followers must send a follow request that you approve or " + + "reject. Existing followers are not affected \u2014 they remain followers " + + "unless you remove them.", + checked = settings.isPrivateAccountOrDefault, + onCheckedChange = onTogglePrivateAccount, + tag = SettingsTestTags.PRIVATE_ACCOUNT, + ) + Text( + text = "People who aren\u2019t approved followers can\u2019t see your private " + + "messages. Approve or reject pending requests from Account, then Follow " + + "requests. Switching back to public doesn\u2019t automatically re-expose " + + "content to followers you never approved.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .testTag(SettingsTestTags.PRIVATE_ACCOUNT_NOTE), + ) + } +} + /** A failed save reported inline above the groups, dismissible by the user. */ @Composable private fun SaveErrorBanner(message: String, onDismiss: () -> Unit) { @@ -341,6 +406,7 @@ private fun SettingsScreenPreview() { viewingPreference = ViewingPreference.FOLLOWING, showPreviews = true, showAdvancedPostSettings = false, + isPrivateAccount = true, ), ), onBack = {}, @@ -351,6 +417,7 @@ private fun SettingsScreenPreview() { onSetMaxMessageLength = {}, onToggleDefaultPubliclyVisible = {}, onToggleShowAdvancedPostSettings = {}, + onTogglePrivateAccount = {}, onDismissError = {}, ) } diff --git a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt index 85e7950..5fce0c2 100644 --- a/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt +++ b/feature/profile/src/main/kotlin/com/interlinedlist/android/feature/profile/ui/settings/SettingsViewModel.kt @@ -9,6 +9,7 @@ import com.interlinedlist.android.feature.profile.domain.UserSettings import com.interlinedlist.android.feature.profile.domain.UserSettingsUpdate import com.interlinedlist.android.feature.profile.domain.ViewingPreference import com.interlinedlist.android.feature.profile.domain.defaultPubliclyVisibleOrDefault +import com.interlinedlist.android.feature.profile.domain.isPrivateAccountOrDefault import com.interlinedlist.android.feature.profile.domain.maxMessageLengthOrDefault import com.interlinedlist.android.feature.profile.domain.messagesPerPageOrDefault import com.interlinedlist.android.feature.profile.domain.showAdvancedPostSettingsOrDefault @@ -117,6 +118,25 @@ class SettingsViewModel @Inject constructor( ) } + /** + * Makes the account private, or public again. + * + * Turning it on does not disturb existing followers — the help centre is explicit + * that "Existing followers are not affected; they remain followers unless you + * remove them" (`/help/people`, "Private accounts") — it only makes *new* follows + * arrive as requests to approve or reject. A failed save rolls back, so the switch + * never claims a privacy state the server did not accept. + */ + fun setPrivateAccount(private: Boolean) { + val current = _uiState.value.settings ?: return + if (current.isPrivateAccountOrDefault == private) return + save( + optimistic = current.copy(isPrivateAccount = private), + previous = current, + update = UserSettingsUpdate(isPrivateAccount = private), + ) + } + /** * Sets the account's message character limit. Values outside * [SettingsBounds.MAX_MESSAGE_LENGTH] are refused here, so a bad number never diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt index 96bddb3..238a4fe 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/data/DefaultSettingsRepositoryTest.kt @@ -65,6 +65,7 @@ class DefaultSettingsRepositoryTest { defaultPubliclyVisible: Boolean = true, messagesPerPage: Int = 20, showAdvancedPostSettings: Boolean = false, + isPrivateAccount: Boolean = false, ) = server.enqueue( MockResponse().setResponseCode(200).setBody( """ @@ -82,7 +83,7 @@ class DefaultSettingsRepositoryTest { "showAdvancedPostSettings": $showAdvancedPostSettings, "latitude": 45.52, "longitude": -122.68, - "isPrivateAccount": false, + "isPrivateAccount": $isPrivateAccount, "githubDefaultRepo": "adron/notes", "notificationTrayLimit": 25 } @@ -300,4 +301,49 @@ class DefaultSettingsRepositoryTest { assertThat(result).isInstanceOf(ApiResult.Failure::class.java) assertThat(repository.observeSettings().first()?.maxMessageLength).isEqualTo(666) } + + // --- Private account (issue #34) ----------------------------------------- + + @Test + fun `isPrivateAccount PATCHes alone as a JSON boolean`() = runTest(testDispatcher) { + enqueueUser(isPrivateAccount = true) + + val result = repository.update(UserSettingsUpdate(isPrivateAccount = true)) + + val body = server.takeJsonBody() + assertThat(body.keys).containsExactly("isPrivateAccount") + val sent = body.getValue("isPrivateAccount").jsonPrimitive + assertThat(sent.isString).isFalse() + assertThat(sent.booleanOrNull).isTrue() + assertThat((result as ApiResult.Success).data.isPrivateAccount).isTrue() + assertThat(repository.observeSettings().first()?.isPrivateAccount).isTrue() + } + + @Test + fun `going public again PATCHes isPrivateAccount alone`() = runTest(testDispatcher) { + enqueueUser(isPrivateAccount = false) + + val result = repository.update(UserSettingsUpdate(isPrivateAccount = false)) + + val body = server.takeJsonBody() + assertThat(body.keys).containsExactly("isPrivateAccount") + assertThat(body.getValue("isPrivateAccount").jsonPrimitive.booleanOrNull).isFalse() + assertThat((result as ApiResult.Success).data.isPrivateAccount).isFalse() + } + + @Test + fun `a rejected private-account save leaves the cached value untouched`() = + runTest(testDispatcher) { + enqueueUser(isPrivateAccount = false) + repository.refresh() + server.takeRequest() + + server.enqueue( + MockResponse().setResponseCode(500).setBody("""{ "error": "boom" }"""), + ) + val result = repository.update(UserSettingsUpdate(isPrivateAccount = true)) + + assertThat(result).isInstanceOf(ApiResult.Failure::class.java) + assertThat(repository.observeSettings().first()?.isPrivateAccount).isFalse() + } } diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt index 2adaa93..64ed648 100644 --- a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/FakeSettingsRepository.kt @@ -49,6 +49,7 @@ class FakeSettingsRepository : SettingsRepository { messagesPerPage = update.messagesPerPage ?: current.messagesPerPage, showAdvancedPostSettings = update.showAdvancedPostSettings ?: current.showAdvancedPostSettings, + isPrivateAccount = update.isPrivateAccount ?: current.isPrivateAccount, ) } } diff --git a/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsPrivateAccountTest.kt b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsPrivateAccountTest.kt new file mode 100644 index 0000000..bb303d8 --- /dev/null +++ b/feature/profile/src/test/kotlin/com/interlinedlist/android/feature/profile/ui/SettingsPrivateAccountTest.kt @@ -0,0 +1,196 @@ +package com.interlinedlist.android.feature.profile.ui + +import app.cash.turbine.test +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.profile.domain.SettingsBounds +import com.interlinedlist.android.feature.profile.domain.UserSettings +import com.interlinedlist.android.feature.profile.domain.isPrivateAccountOrDefault +import com.interlinedlist.android.feature.profile.ui.settings.SettingsViewModel +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 + +/** + * The private-account toggle (issue #34) — the Permissions group. + * + * Making an account private changes who can see the owner's content, so the write + * has to behave exactly like the other preferences: its own partial PATCH, applied + * optimistically and rolled back when the server refuses, with the screen state + * always reporting what the server last said. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsPrivateAccountTest { + + private val dispatcher = StandardTestDispatcher() + private lateinit var repo: FakeSettingsRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repo = FakeSettingsRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + /** A view model already loaded with [settings]. */ + private fun loadedViewModel(settings: UserSettings): SettingsViewModel { + repo.refreshResult = ApiResult.Success(settings) + return SettingsViewModel(repo) + } + + private val serverErrorMessage = "InterlinedList is having trouble right now. Try again shortly." + + @Test + fun `going private saves only that field`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(isPrivateAccount = false)) + + vm.uiState.test { + advanceUntilIdle() + vm.setPrivateAccount(true) + + // Applied optimistically, before the request comes back. + assertThat(vm.uiState.value.settings?.isPrivateAccount).isTrue() + assertThat(vm.uiState.value.isSaving).isTrue() + advanceUntilIdle() + + val sent = repo.updates.single() + assertThat(sent.isPrivateAccount).isTrue() + assertThat(sent.touchedFieldNames()).containsExactly("isPrivateAccount") + val state = expectMostRecentItem() + assertThat(state.settings?.isPrivateAccount).isTrue() + assertThat(state.isSaving).isFalse() + assertThat(state.errorMessage).isNull() + } + } + + @Test + fun `going public again saves only that field`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(isPrivateAccount = true)) + + vm.uiState.test { + advanceUntilIdle() + vm.setPrivateAccount(false) + advanceUntilIdle() + + val sent = repo.updates.single() + assertThat(sent.isPrivateAccount).isFalse() + assertThat(sent.touchedFieldNames()).containsExactly("isPrivateAccount") + assertThat(expectMostRecentItem().settings?.isPrivateAccount).isFalse() + } + } + + @Test + fun `a failed save rolls the account back to public and surfaces the error`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(isPrivateAccount = false)) + repo.updateResult = { ApiResult.Failure(AppError.Server("boom")) } + + vm.uiState.test { + advanceUntilIdle() + vm.setPrivateAccount(true) + assertThat(vm.uiState.value.settings?.isPrivateAccount).isTrue() + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(state.settings?.isPrivateAccount).isFalse() + assertThat(state.isSaving).isFalse() + assertThat(state.errorMessage).isEqualTo(serverErrorMessage) + } + } + + @Test + fun `a failed save on a private account leaves it private`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(isPrivateAccount = true)) + repo.updateResult = { ApiResult.Failure(AppError.Network("offline")) } + + vm.uiState.test { + advanceUntilIdle() + vm.setPrivateAccount(false) + advanceUntilIdle() + + val state = expectMostRecentItem() + assertThat(state.settings?.isPrivateAccount).isTrue() + assertThat(state.errorMessage) + .isEqualTo("No connection. Check your network and try again.") + } + } + + @Test + fun `setting the value already stored does not call the API`() = runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(isPrivateAccount = true)) + advanceUntilIdle() + + vm.setPrivateAccount(true) + advanceUntilIdle() + + assertThat(repo.updates).isEmpty() + assertThat(vm.uiState.value.settings?.isPrivateAccount).isTrue() + } + + @Test + fun `an account with no stored value reads as public and can still be made private`() = + runTest(dispatcher) { + // The API may omit the field; the row shows "public" for it, so turning the + // switch off must not spend a request while turning it on must. + val vm = loadedViewModel(UserSettings(isPrivateAccount = null)) + advanceUntilIdle() + + assertThat(vm.uiState.value.settings?.isPrivateAccountOrDefault).isFalse() + vm.setPrivateAccount(false) + advanceUntilIdle() + assertThat(repo.updates).isEmpty() + + vm.setPrivateAccount(true) + advanceUntilIdle() + assertThat(repo.updates.single().isPrivateAccount).isTrue() + } + + @Test + fun `the state shows what the server last said after a refresh`() = runTest(dispatcher) { + // The screen renders the switch from this state, so a refresh that brings back + // a value changed elsewhere (the web, another device) must be reflected here. + val vm = loadedViewModel(UserSettings(isPrivateAccount = false)) + + vm.uiState.test { + advanceUntilIdle() + assertThat(vm.uiState.value.settings?.isPrivateAccountOrDefault).isFalse() + + repo.refreshResult = ApiResult.Success(UserSettings(isPrivateAccount = true)) + vm.refresh() + advanceUntilIdle() + + assertThat(expectMostRecentItem().settings?.isPrivateAccountOrDefault).isTrue() + } + } + + @Test + fun `a refresh triggered elsewhere is reflected without reloading this screen`() = + runTest(dispatcher) { + val vm = loadedViewModel(UserSettings(isPrivateAccount = false)) + + vm.uiState.test { + advanceUntilIdle() + repo.refreshResult = ApiResult.Success(UserSettings(isPrivateAccount = true)) + repo.refresh() + advanceUntilIdle() + + assertThat(expectMostRecentItem().settings?.isPrivateAccountOrDefault).isTrue() + } + } + + @Test + fun `an account is public unless it says otherwise`() { + assertThat(SettingsBounds.DEFAULT_PRIVATE_ACCOUNT).isFalse() + assertThat(UserSettings().isPrivateAccountOrDefault).isFalse() + assertThat(UserSettings(isPrivateAccount = true).isPrivateAccountOrDefault).isTrue() + } +}