From 8794729b84436bae0535931e4c183159d99d499f Mon Sep 17 00:00:00 2001 From: Nocturnal <241546228+fullynocturnal@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:25:29 -0400 Subject: [PATCH] feat(safety): show every safety category, and warn before a raise goes public The System Safety screen knew nine of the server's fifteen categories. The other six (notifications, reminders, polls, messages, relationships and archive) came back from the server and were dropped on parse, so they could be neither seen nor changed from the phone. profile_visibility was missing with them, and it is the one category that gates making things more visible rather than deleting them. All fifteen are now on the screen, worded as on the web card. Turning any of them off is a loosening, so it asks for re-auth like the others already did. The toggle list now takes the draft and one callback instead of a lambda per category, which at fifteen would have been unreadable. A field an older instance does not send parses to the server's own default. **Pending exposures.** GET /system/safety carries pending_exposures: raises to public that are waiting out the grace window. They get a read-only section on the safety screen and a banner on Home beside the pending-delete ones, in the web banner's wording. Read-only on purpose, since un-publishing happens where the thing was published, not here. Also names the seven pending-action types that were showing as raw snake_case. --- .../systems/lupine/sheaf/data/model/Models.kt | 29 ++++ .../lupine/sheaf/ui/home/HomeScreen.kt | 16 +- .../lupine/sheaf/ui/home/HomeViewModel.kt | 3 + .../sheaf/ui/settings/SystemSafetyScreen.kt | 143 ++++++++++++++---- .../ui/settings/SystemSafetyViewModel.kt | 17 +++ .../data/model/SystemSafetyParsingTest.kt | 30 +++- .../ui/settings/SystemSafetyLooseningTest.kt | 65 ++++++++ 7 files changed, 274 insertions(+), 29 deletions(-) create mode 100644 sheaf/app/src/test/java/systems/lupine/sheaf/ui/settings/SystemSafetyLooseningTest.kt diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt index ab62988..c76c81f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/Models.kt @@ -296,6 +296,19 @@ data class SystemSafetySettings( @Json(name = "applies_to_journals") val appliesToJournals: Boolean, @Json(name = "applies_to_images") val appliesToImages: Boolean, @Json(name = "applies_to_revisions") val appliesToRevisions: Boolean = false, + @Json(name = "applies_to_notifications") val appliesToNotifications: Boolean = false, + @Json(name = "applies_to_reminders") val appliesToReminders: Boolean = false, + @Json(name = "applies_to_polls") val appliesToPolls: Boolean = false, + @Json(name = "applies_to_messages") val appliesToMessages: Boolean = false, + // Deleting a relationship TYPE, which takes every edge drawn with it. The + // per-edge visibility controls are a separate thing. + @Json(name = "applies_to_relationships") val appliesToRelationships: Boolean = false, + // No grace-able pending action behind this one: it only gates whether + // archiving a member needs re-auth. + @Json(name = "applies_to_archive") val appliesToArchive: Boolean = false, + // Armed by default server-side; the default here matches so an instance that + // predates the category doesn't read as unarmed. + @Json(name = "applies_to_profile_visibility") val appliesToProfileVisibility: Boolean = true, @Json(name = "auto_pin_first_revision") val autoPinFirstRevision: Boolean = true, ) @@ -311,6 +324,13 @@ data class SystemSafetyUpdate( @Json(name = "applies_to_journals") val appliesToJournals: Boolean? = null, @Json(name = "applies_to_images") val appliesToImages: Boolean? = null, @Json(name = "applies_to_revisions") val appliesToRevisions: Boolean? = null, + @Json(name = "applies_to_notifications") val appliesToNotifications: Boolean? = null, + @Json(name = "applies_to_reminders") val appliesToReminders: Boolean? = null, + @Json(name = "applies_to_polls") val appliesToPolls: Boolean? = null, + @Json(name = "applies_to_messages") val appliesToMessages: Boolean? = null, + @Json(name = "applies_to_relationships") val appliesToRelationships: Boolean? = null, + @Json(name = "applies_to_archive") val appliesToArchive: Boolean? = null, + @Json(name = "applies_to_profile_visibility") val appliesToProfileVisibility: Boolean? = null, @Json(name = "auto_pin_first_revision") val autoPinFirstRevision: Boolean? = null, val password: String? = null, @Json(name = "totp_code") val totpCode: String? = null, @@ -342,11 +362,20 @@ data class SafetyChangeRequestRead( val status: String, ) +// A staged raise-to-public waiting out the grace window. Count and time only, +// no entity label, matching the pending-delete banner. +@JsonClass(generateAdapter = true) +data class PendingExposureRead( + val kind: String, + @Json(name = "activates_at") val activatesAt: String, +) + @JsonClass(generateAdapter = true) data class SystemSafetyResponse( val settings: SystemSafetySettings, @Json(name = "pending_actions") val pendingActions: List, @Json(name = "pending_changes") val pendingChanges: List, + @Json(name = "pending_exposures") val pendingExposures: List = emptyList(), ) @JsonClass(generateAdapter = true) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt index 059746a..9e0bbdb 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeScreen.kt @@ -157,6 +157,17 @@ fun HomeScreen( modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), ) } + val pendingExposuresCount = state.pendingSafetyExposures.size + if (pendingExposuresCount > 0) { + val earliest = state.pendingSafetyExposures.mapNotNull { parseFinalize(it.activatesAt) }.minOrNull() + SafetyPendingBanner( + kind = SafetyBannerKind.EXPOSURES, + count = pendingExposuresCount, + earliestFinalize = earliest, + onClick = onNavigateToSystemSafety, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } state.pendingTrimNotice?.let { notice -> TrimNoticePendingBanner( notice = notice, @@ -612,7 +623,7 @@ private fun SwitchFrontSheet( // ── System Safety pending banner ────────────────────────────────────────────── -private enum class SafetyBannerKind { ACTIONS, CHANGES } +private enum class SafetyBannerKind { ACTIONS, CHANGES, EXPOSURES } @Composable private fun SafetyPendingBanner( @@ -664,6 +675,9 @@ private fun safetyBannerMessage(kind: SafetyBannerKind, count: Int, earliest: Of SafetyBannerKind.CHANGES -> if (count == 1) "Safety settings change pending — finalizes $time." else "$count safety settings changes pending — next finalizes $time." + SafetyBannerKind.EXPOSURES -> + if (count == 1) "1 change that makes something public takes effect $time." + else "$count changes that make something public. Next takes effect $time." } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeViewModel.kt index 161163c..8b5012f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/home/HomeViewModel.kt @@ -16,6 +16,7 @@ import systems.lupine.sheaf.data.model.FrontUpdate import systems.lupine.sheaf.data.model.GroupRead import systems.lupine.sheaf.data.model.MemberRead import systems.lupine.sheaf.data.model.PendingActionRead +import systems.lupine.sheaf.data.model.PendingExposureRead import systems.lupine.sheaf.data.model.SafetyChangeRequestRead import systems.lupine.sheaf.data.model.SystemRead import systems.lupine.sheaf.data.model.UserRead @@ -77,6 +78,7 @@ data class HomeUiState( val pendingOpCount: Int = 0, val pendingSafetyActions: List = emptyList(), val pendingSafetyChanges: List = emptyList(), + val pendingSafetyExposures: List = emptyList(), // Pending revision-retention trim notice from /v1/retention. Set when the // server has a status="pending" notice (typically a tier downgrade). val pendingTrimNotice: systems.lupine.sheaf.data.model.RetentionTrimNoticeRead? = null, @@ -348,6 +350,7 @@ class HomeViewModel @Inject constructor( it.copy( pendingSafetyActions = safetyResp.pendingActions, pendingSafetyChanges = safetyResp.pendingChanges, + pendingSafetyExposures = safetyResp.pendingExposures, ) } } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyScreen.kt index 70e935d..daa6765 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyScreen.kt @@ -21,6 +21,7 @@ import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import java.time.format.DateTimeParseException import systems.lupine.sheaf.data.model.PendingActionRead +import systems.lupine.sheaf.data.model.PendingExposureRead import systems.lupine.sheaf.data.model.SafetyChangeRequestRead import systems.lupine.sheaf.data.model.SystemSafetySettings import systems.lupine.sheaf.ui.components.* @@ -102,14 +103,7 @@ fun SystemSafetyScreen( SectionHeader("Apply safety to") CategoryToggles( draft = draft, - onMembers = { viewModel.updateDraft { copy(appliesToMembers = it) } }, - onGroups = { viewModel.updateDraft { copy(appliesToGroups = it) } }, - onTags = { viewModel.updateDraft { copy(appliesToTags = it) } }, - onFields = { viewModel.updateDraft { copy(appliesToFields = it) } }, - onFronts = { viewModel.updateDraft { copy(appliesToFronts = it) } }, - onJournals = { viewModel.updateDraft { copy(appliesToJournals = it) } }, - onImages = { viewModel.updateDraft { copy(appliesToImages = it) } }, - onRevisions = { viewModel.updateDraft { copy(appliesToRevisions = it) } }, + onChange = { next -> viewModel.updateDraft { next } }, ) SectionHeader("Revision history") @@ -178,6 +172,16 @@ fun SystemSafetyScreen( ) } } + + if (state.pendingExposures.isNotEmpty()) { + SectionHeader("Pending public changes") + state.pendingExposures.forEach { exposure -> + PendingExposureRow( + exposure = exposure, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + } } } @@ -359,30 +363,64 @@ private fun AuthTierRowSupportingText(needsTotp: Boolean, totpEnabled: Boolean) @Composable private fun CategoryToggles( draft: SystemSafetySettings, - onMembers: (Boolean) -> Unit, - onGroups: (Boolean) -> Unit, - onTags: (Boolean) -> Unit, - onFields: (Boolean) -> Unit, - onFronts: (Boolean) -> Unit, - onJournals: (Boolean) -> Unit, - onImages: (Boolean) -> Unit, - onRevisions: (Boolean) -> Unit, + onChange: (SystemSafetySettings) -> Unit, ) { - data class CategoryRow(val label: String, val supporting: String?, val value: Boolean, val onChange: (Boolean) -> Unit) + data class CategoryRow( + val label: String, + val supporting: String?, + val value: Boolean, + val apply: (Boolean) -> SystemSafetySettings, + ) val items = listOf( - CategoryRow("Members", null, draft.appliesToMembers, onMembers), - CategoryRow("Groups", null, draft.appliesToGroups, onGroups), - CategoryRow("Tags", null, draft.appliesToTags, onTags), - CategoryRow("Custom fields", null, draft.appliesToFields, onFields), - CategoryRow("Fronts", null, draft.appliesToFronts, onFronts), - CategoryRow("Journal entries", null, draft.appliesToJournals, onJournals), - CategoryRow("Images", null, draft.appliesToImages, onImages), + CategoryRow("Members", null, draft.appliesToMembers) { draft.copy(appliesToMembers = it) }, + CategoryRow("Groups", null, draft.appliesToGroups) { draft.copy(appliesToGroups = it) }, + CategoryRow("Tags", null, draft.appliesToTags) { draft.copy(appliesToTags = it) }, + CategoryRow("Custom fields", null, draft.appliesToFields) { draft.copy(appliesToFields = it) }, + CategoryRow("Fronts", null, draft.appliesToFronts) { draft.copy(appliesToFronts = it) }, + CategoryRow("Journal entries", null, draft.appliesToJournals) { draft.copy(appliesToJournals = it) }, + CategoryRow("Images", null, draft.appliesToImages) { draft.copy(appliesToImages = it) }, CategoryRow( "Pinned revisions", "Require re-auth and grace before unpinning", draft.appliesToRevisions, - onRevisions, - ), + ) { draft.copy(appliesToRevisions = it) }, + CategoryRow( + "Notifications", + "Deleting a channel or revoking a watcher", + draft.appliesToNotifications, + ) { draft.copy(appliesToNotifications = it) }, + CategoryRow( + "Reminders", + "Deleting a reminder", + draft.appliesToReminders, + ) { draft.copy(appliesToReminders = it) }, + CategoryRow( + "Polls", + "Deleting a poll, with its votes and audit log", + draft.appliesToPolls, + ) { draft.copy(appliesToPolls = it) }, + CategoryRow( + "Messages", + "Deleting a board message or thread", + draft.appliesToMessages, + ) { draft.copy(appliesToMessages = it) }, + CategoryRow( + "Relationships", + "Deleting a relationship type, which takes every relationship drawn with it", + draft.appliesToRelationships, + ) { draft.copy(appliesToRelationships = it) }, + CategoryRow( + "Archive members", + "Re-auth to archive a member. No grace period, speed-bump only.", + draft.appliesToArchive, + ) { draft.copy(appliesToArchive = it) }, + CategoryRow( + "Profile visibility", + "Gates making things more visible rather than deleting them: publishing a " + + "share view, adding someone to one that is already shared, or raising " + + "anything to public. With no grace period it just asks for re-auth first.", + draft.appliesToProfileVisibility, + ) { draft.copy(appliesToProfileVisibility = it) }, ) Column { items.forEachIndexed { index, row -> @@ -390,7 +428,7 @@ private fun CategoryToggles( headlineContent = { Text(row.label) }, supportingContent = row.supporting?.let { { Text(it) } }, trailingContent = { - Switch(checked = row.value, onCheckedChange = row.onChange) + Switch(checked = row.value, onCheckedChange = { onChange(row.apply(it)) }) }, ) if (index != items.lastIndex) HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) @@ -506,6 +544,32 @@ private fun PendingChangeRow( } } +// Read-only: un-exposing is immediate and ungated, but it happens where the +// change was made (the share view, the member, the relationship), not here. +@Composable +private fun PendingExposureRow( + exposure: PendingExposureRead, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text(formatExposureKind(exposure.kind), style = MaterialTheme.typography.bodyMedium) + Text( + "Becomes public ${formatRelativeTime(exposure.activatesAt)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @Composable private fun ReauthDialog( authTier: String, @@ -592,6 +656,13 @@ private fun formatField(field: String): String = when (field) { "applies_to_journals" -> "journal entries" "applies_to_images" -> "images" "applies_to_revisions" -> "pinned revisions" + "applies_to_notifications" -> "notifications" + "applies_to_reminders" -> "reminders" + "applies_to_polls" -> "polls" + "applies_to_messages" -> "messages" + "applies_to_relationships" -> "relationships" + "applies_to_archive" -> "archiving members" + "applies_to_profile_visibility" -> "profile visibility" "auto_pin_first_revision" -> "auto-pin first revision" else -> field } @@ -605,9 +676,27 @@ private fun formatActionType(type: String): String = when (type) { "journal_delete" -> "Delete journal" "image_delete" -> "Delete image" "revision_unpin" -> "Unpin revision" + "watch_token_revoke" -> "Revoke watcher" + "channel_delete" -> "Delete channel" + "reminder_delete" -> "Delete reminder" + "poll_delete" -> "Delete poll" + "message_delete" -> "Delete message" + "message_thread_delete" -> "Delete thread" + "relationship_type_delete" -> "Delete relationship type" else -> type.replace('_', ' ').replaceFirstChar { it.uppercase() } } +private fun formatExposureKind(kind: String): String = when (kind) { + "system_privacy" -> "System set to public" + "member_privacy" -> "Member raised to public" + "member_fronting" -> "Member's fronting made visible" + "group_privacy" -> "Group raised to public" + "field_privacy" -> "Custom field raised to public" + "relationship_privacy" -> "Relationship raised to public" + "view_flags" -> "Share view showing more" + else -> kind.replace('_', ' ').replaceFirstChar { it.uppercase() } +} + private fun formatChangesSummary(changes: Map): String { if (changes.isEmpty()) return "Pending change" return changes.entries.joinToString("; ") { (k, v) -> diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyViewModel.kt index f582bca..a027364 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/settings/SystemSafetyViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.launch import retrofit2.HttpException import systems.lupine.sheaf.data.api.SheafApiService import systems.lupine.sheaf.data.model.PendingActionRead +import systems.lupine.sheaf.data.model.PendingExposureRead import systems.lupine.sheaf.data.model.SafetyChangeRequestRead import systems.lupine.sheaf.data.model.SystemSafetySettings import systems.lupine.sheaf.data.model.SystemSafetyUpdate @@ -25,6 +26,7 @@ data class SystemSafetyUiState( val draft: SystemSafetySettings? = null, val pendingActions: List = emptyList(), val pendingChanges: List = emptyList(), + val pendingExposures: List = emptyList(), val totpEnabled: Boolean = false, val isSaving: Boolean = false, val saveError: String? = null, @@ -64,6 +66,7 @@ class SystemSafetyViewModel @Inject constructor( draft = resp.settings, pendingActions = resp.pendingActions, pendingChanges = resp.pendingChanges, + pendingExposures = resp.pendingExposures, totpEnabled = user?.totpEnabled == true, ) } @@ -197,6 +200,13 @@ class SystemSafetyViewModel @Inject constructor( appliesToJournals = draft.appliesToJournals.takeIf { it != current.appliesToJournals }, appliesToImages = draft.appliesToImages.takeIf { it != current.appliesToImages }, appliesToRevisions = draft.appliesToRevisions.takeIf { it != current.appliesToRevisions }, + appliesToNotifications = draft.appliesToNotifications.takeIf { it != current.appliesToNotifications }, + appliesToReminders = draft.appliesToReminders.takeIf { it != current.appliesToReminders }, + appliesToPolls = draft.appliesToPolls.takeIf { it != current.appliesToPolls }, + appliesToMessages = draft.appliesToMessages.takeIf { it != current.appliesToMessages }, + appliesToRelationships = draft.appliesToRelationships.takeIf { it != current.appliesToRelationships }, + appliesToArchive = draft.appliesToArchive.takeIf { it != current.appliesToArchive }, + appliesToProfileVisibility = draft.appliesToProfileVisibility.takeIf { it != current.appliesToProfileVisibility }, autoPinFirstRevision = draft.autoPinFirstRevision.takeIf { it != current.autoPinFirstRevision }, password = password?.ifBlank { null }, totpCode = totpCode?.ifBlank { null }, @@ -222,6 +232,13 @@ class SystemSafetyViewModel @Inject constructor( if (current.appliesToJournals && !draft.appliesToJournals) return true if (current.appliesToImages && !draft.appliesToImages) return true if (current.appliesToRevisions && !draft.appliesToRevisions) return true + if (current.appliesToNotifications && !draft.appliesToNotifications) return true + if (current.appliesToReminders && !draft.appliesToReminders) return true + if (current.appliesToPolls && !draft.appliesToPolls) return true + if (current.appliesToMessages && !draft.appliesToMessages) return true + if (current.appliesToRelationships && !draft.appliesToRelationships) return true + if (current.appliesToArchive && !draft.appliesToArchive) return true + if (current.appliesToProfileVisibility && !draft.appliesToProfileVisibility) return true if (current.autoPinFirstRevision && !draft.autoPinFirstRevision) return true return false } diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/SystemSafetyParsingTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/SystemSafetyParsingTest.kt index a68d9ad..3db7b92 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/SystemSafetyParsingTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/model/SystemSafetyParsingTest.kt @@ -37,7 +37,14 @@ class SystemSafetyParsingTest { "applies_to_fields": true, "applies_to_fronts": true, "applies_to_journals": true, - "applies_to_images": false + "applies_to_images": false, + "applies_to_notifications": true, + "applies_to_reminders": false, + "applies_to_polls": true, + "applies_to_messages": false, + "applies_to_relationships": true, + "applies_to_archive": true, + "applies_to_profile_visibility": true }, "pending_actions": [ { @@ -53,6 +60,12 @@ class SystemSafetyParsingTest { "status": "pending" } ], + "pending_exposures": [ + { + "kind": "view_flags", + "activates_at": "2026-05-06T20:02:00Z" + } + ], "pending_changes": [ { "id": "chg-1", @@ -76,6 +89,17 @@ class SystemSafetyParsingTest { assertEquals("totp", parsed.settings.authTier) assertTrue(parsed.settings.appliesToMembers) assertTrue(parsed.settings.appliesToJournals) + assertTrue(parsed.settings.appliesToProfileVisibility) + assertTrue(parsed.settings.appliesToNotifications) + assertEquals(false, parsed.settings.appliesToReminders) + assertTrue(parsed.settings.appliesToPolls) + assertEquals(false, parsed.settings.appliesToMessages) + assertTrue(parsed.settings.appliesToRelationships) + assertTrue(parsed.settings.appliesToArchive) + + val exposure = parsed.pendingExposures.single() + assertEquals("view_flags", exposure.kind) + assertEquals("2026-05-06T20:02:00Z", exposure.activatesAt) assertEquals(1, parsed.pendingActions.size) val action = parsed.pendingActions.single() @@ -117,6 +141,10 @@ class SystemSafetyParsingTest { assertEquals("password", parsed.settings.authTier) assertTrue(parsed.pendingActions.isEmpty()) assertTrue(parsed.pendingChanges.isEmpty()) + // Absent on an instance that predates the category; the category itself + // defaults to armed, matching the server. + assertTrue(parsed.pendingExposures.isEmpty()) + assertTrue(parsed.settings.appliesToProfileVisibility) } @Test diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/ui/settings/SystemSafetyLooseningTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/settings/SystemSafetyLooseningTest.kt new file mode 100644 index 0000000..3359861 --- /dev/null +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/ui/settings/SystemSafetyLooseningTest.kt @@ -0,0 +1,65 @@ +package systems.lupine.sheaf.ui.settings + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import systems.lupine.sheaf.data.model.SystemSafetySettings + +/** + * The re-auth prompt is decided client-side from the diff, so a category that + * can be disarmed has to be in `isLoosening` or the save silently skips the + * dialog and eats a 400 from the server instead. + */ +class SystemSafetyLooseningTest { + + private val armed = SystemSafetySettings( + gracePeriodDays = 7, + authTier = "password", + appliesToMembers = true, + appliesToGroups = true, + appliesToTags = true, + appliesToFields = true, + appliesToFronts = true, + appliesToJournals = true, + appliesToImages = true, + appliesToRevisions = true, + appliesToNotifications = true, + appliesToReminders = true, + appliesToPolls = true, + appliesToMessages = true, + appliesToRelationships = true, + appliesToArchive = true, + appliesToProfileVisibility = true, + autoPinFirstRevision = true, + ) + + @Test + fun `disarming profile visibility is a loosening`() { + val draft = armed.copy(appliesToProfileVisibility = false) + assertTrue(SystemSafetyViewModel.isLoosening(armed, draft)) + } + + @Test + fun `disarming any other category is a loosening too`() { + val cases = listOf>( + "notifications" to armed.copy(appliesToNotifications = false), + "reminders" to armed.copy(appliesToReminders = false), + "polls" to armed.copy(appliesToPolls = false), + "messages" to armed.copy(appliesToMessages = false), + "relationships" to armed.copy(appliesToRelationships = false), + "archive" to armed.copy(appliesToArchive = false), + ) + cases.forEach { (name, draft) -> + assertTrue( + SystemSafetyViewModel.isLoosening(armed, draft), + "disarming $name should require re-auth", + ) + } + } + + @Test + fun `arming profile visibility is not a loosening`() { + val current = armed.copy(appliesToProfileVisibility = false) + assertFalse(SystemSafetyViewModel.isLoosening(current, armed)) + } +}