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() + } +}