diff --git a/CHANGELOG.md b/CHANGELOG.md index 70bda96..f6d0e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,35 @@ uses semantic versioning (`MAJOR.MINOR.PATCH`). ### Added +- **A share view can show everyone set to Public.** Instead of adding members + one at a time, turn on "Show everyone set to Public" and the view follows + each member's privacy setting from then on. The list you picked by hand is + kept but not used while this is on, and comes back if you turn it off. + Members marked never shareable are still never shown. On a view that's + already live, turning it on asks you to re-authenticate and waits out the + grace period like any other way of showing more. Needs a server running + 1.6.0 or later. + +- **Import from BerryTree (experimental).** Brings across members, custom + statuses (as custom fronts), front history, folders (as groups), tags and + custom fields. Template members are left out unless you tick them. Before + anything is imported, the preview lists every part of your export that + can't be read yet. Needs a server running 1.6.0 or later. + +### Changed + +- **Raising a member to Public now shows when it takes effect.** With a + System Safety grace period set, the member editor says when a member will + go Public, and picking a lower level cancels it. Editing anything else + about them no longer cancels the wait by accident. + +### Fixed + +- **Re-authentication now reaches the server when editing members, groups + and system settings.** The password and code you typed were dropped before + the request went out, so a change that needed them could never save. + "Never shareable" and "Keep fronting private" on a member now save too, and + so does changing a group's privacy. - **Pin journal entries.** The pin button at the top of an entry keeps it in a Pinned section above the rest of the journal list. With the Journal entries toggle in System Safety on, unpinning asks you to re-authenticate and waits diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt index 9e08a93..851dce6 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/PatchJsonAdapters.kt @@ -77,6 +77,10 @@ class MemberUpdateJsonAdapter(moshi: Moshi) : JsonAdapter() { clears("birthday", value.birthday) clears("note", value.note) clears("emoji", value.emoji) + omitsWhenNull("never_shareable", value.neverShareable) + omitsWhenNull("fronting_private", value.frontingPrivate) + omitsWhenNull("password", value.password) + omitsWhenNull("totp_code", value.totpCode) } writer.endObject() } @@ -99,10 +103,13 @@ class GroupUpdateJsonAdapter(moshi: Moshi) : JsonAdapter() { writer.beginObject() PatchWriter(writer, anyAdapter).apply { omitsWhenNull("name", value.name) + omitsWhenNull("privacy", value.privacy) clears("description", value.description) clears("color", value.color) // Clearing this is how a subgroup is promoted back to top level. clears("parent_id", value.parentId) + omitsWhenNull("password", value.password) + omitsWhenNull("totp_code", value.totpCode) } writer.endObject() } @@ -135,6 +142,8 @@ class SystemUpdateJsonAdapter(moshi: Moshi) : JsonAdapter() { clears("avatar_url", value.avatarUrl) clears("color", value.color) clears("note", value.note) + omitsWhenNull("password", value.password) + omitsWhenNull("totp_code", value.totpCode) } writer.endObject() } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt index 77b7f40..bca2cd5 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/api/SheafApiService.kt @@ -639,6 +639,12 @@ interface SheafApiService { @Part file: MultipartBody.Part, ): AmpersandPreviewSummary + @Multipart + @POST("/v1/import/berrytree/preview") + suspend fun previewBerryTreeImport( + @Part file: MultipartBody.Part, + ): BerryTreePreviewSummary + /** * Preview an OpenPlural v0.1 import. Accepts a bare `.json` export or an * `.openplural.zip` bundle (the endpoint sniffs the zip magic). Reuses the 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 67bb1e9..d91298e 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 @@ -509,6 +509,10 @@ data class MemberRead( // Set while a request to drop the fronting guard is waiting out the grace // window. The flag above is still the truth until it passes. @Json(name = "fronting_private_activates_at") val frontingPrivateActivatesAt: String? = null, + // A raise to public waiting out the grace window. `privacy` above is still + // the truth until privacyActivatesAt passes; lowering cancels it. + @Json(name = "pending_privacy") val pendingPrivacy: String? = null, + @Json(name = "privacy_activates_at") val privacyActivatesAt: String? = null, ) { val displayNameOrName: String get() = displayName?.takeIf { it.isNotBlank() } ?: name @@ -548,6 +552,10 @@ data class MemberCreate( // Short glyph shown beside the member's name and in place of an // avatar. Server caps it at 8 code points. val emoji: String? = null, + // Only consulted when creating them public would publish them, i.e. a + // share view serves everyone set to Public. + val password: String? = null, + @Json(name = "totp_code") val totpCode: String? = null, ) /** Optional step-up credentials for archiving a member. Only consulted when @@ -1185,6 +1193,7 @@ object ImportJobSource { // unpacks images when present (no separate archive source like Sheaf). const val OPENPLURAL_FILE = "openplural_file" const val AMPERSAND_FILE = "ampersand_file" + const val BERRYTREE_FILE = "berrytree_file" } // ── Ampersand import ────────────────────────────────────────────────────────── @@ -1210,6 +1219,35 @@ data class AmpersandPreviewSummary( @Json(name = "limit_warnings") val limitWarnings: List = emptyList(), ) +// ── BerryTree import ───────────────────────────────────────────────────────── +// +// Experimental server-side: built from a single sample export, so the preview +// lists every section it cannot read yet instead of dropping them quietly. +// Template members are left out of `members` and counted separately. +@JsonClass(generateAdapter = true) +data class BerryTreePreviewSummary( + @Json(name = "system_name") val systemName: String? = null, + @Json(name = "member_count") val memberCount: Int = 0, + val members: List = emptyList(), + @Json(name = "template_count") val templateCount: Int = 0, + @Json(name = "custom_front_count") val customFrontCount: Int = 0, + @Json(name = "fronting_type_count") val frontingTypeCount: Int = 0, + @Json(name = "front_history_count") val frontHistoryCount: Int = 0, + @Json(name = "folder_count") val folderCount: Int = 0, + @Json(name = "tag_count") val tagCount: Int = 0, + @Json(name = "custom_field_count") val customFieldCount: Int = 0, + @Json(name = "unsupported_sections") val unsupportedSections: List = emptyList(), + // What BerryTree's own exporter says it failed to write. + @Json(name = "export_errors") val exportErrors: List = emptyList(), + @Json(name = "limit_warnings") val limitWarnings: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class BerryTreePreviewItem(val id: String, val name: String) + +@JsonClass(generateAdapter = true) +data class BerryTreeSection(val name: String, val count: Int) + // ── Export ────────────────────────────────────────────────────────────────── /** Async full-backup (with images) request. Step-up: password always, plus diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt index 0189225..363d844 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/data/model/ShareModels.kt @@ -67,6 +67,9 @@ data class ShareViewRead( // when the system is not public, and (member cards) with permalinks off. @Json(name = "link_preview_effective") val linkPreviewEffective: String = PREVIEW_GENERIC, @Json(name = "member_link_preview_effective") val memberLinkPreviewEffective: String = PREVIEW_GENERIC, + // Serve every member set to Public, worked out live, instead of the + // hand-picked list. `members` is kept but not consulted while this is on. + @Json(name = "include_all_public_members") val includeAllPublicMembers: Boolean = false, @Json(name = "created_at") val createdAt: String, @Json(name = "is_shared") val isShared: Boolean, // A flag flip that exposes more on an already-shared view is staged. The @@ -80,6 +83,7 @@ data class ShareViewRead( @Json(name = "pending_include_groups") val pendingIncludeGroups: Boolean? = null, @Json(name = "pending_link_preview_mode") val pendingLinkPreviewMode: String? = null, @Json(name = "pending_member_link_preview_mode") val pendingMemberLinkPreviewMode: String? = null, + @Json(name = "pending_include_all_public_members") val pendingIncludeAllPublicMembers: Boolean? = null, @Json(name = "flags_activate_at") val flagsActivateAt: String? = null, val members: List = emptyList(), val fields: List = emptyList(), @@ -116,6 +120,7 @@ data class ShareViewUpdate( @Json(name = "include_groups") val includeGroups: Boolean? = null, @Json(name = "link_preview_mode") val linkPreviewMode: String? = null, @Json(name = "member_link_preview_mode") val memberLinkPreviewMode: String? = null, + @Json(name = "include_all_public_members") val includeAllPublicMembers: Boolean? = null, @Json(name = "member_permalinks") val memberPermalinks: Boolean? = null, val password: String? = null, @Json(name = "totp_code") val totpCode: String? = null, @@ -200,6 +205,9 @@ data class ShareAuditEntry( @Json(name = "include_fronting") val includeFronting: Boolean, @Json(name = "include_relationships") val includeRelationships: Boolean, @Json(name = "include_groups") val includeGroups: Boolean, + // With this on, member_count is curation that is not being used and only + // served_member_count describes the page. + @Json(name = "include_all_public_members") val includeAllPublicMembers: Boolean = false, @Json(name = "member_permalinks") val memberPermalinks: Boolean, @Json(name = "relationship_count") val relationshipCount: Int, @Json(name = "group_count") val groupCount: Int, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSources.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSources.kt index 23db0df..a1f7f3f 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSources.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/importflow/ImportSources.kt @@ -455,6 +455,63 @@ private val ampersand = ImportSource( }, ) +private val berryTree = ImportSource( + id = "berrytree", + source = ImportJobSource.BERRYTREE_FILE, + label = "BerryTree", + title = "Import from BerryTree", + subtitle = "Experimental: use a BerryTree .json export", + constants = mapOf(CONFLICT_SKIP to "\"skip\""), + alwaysEmitMemberIds = true, + input = ImportInput.File( + prompt = "Choose your BerryTree export to get started.", + help = "Export your data from BerryTree as JSON, then select the file here. This importer is " + + "experimental: the preview lists anything in your file it can't read yet before " + + "anything is imported.", + ), + preview = { api, req -> + val s = api.previewBerryTreeImport(req.file!!) + val x = mutableListOf() + s.unsupportedSections.forEach { x += "${it.name} (${it.count}) - not supported yet, will be skipped" } + if (s.exportErrors.isNotEmpty()) { + x += "BerryTree reported problems writing this export, so it was incomplete before it got here:" + x += s.exportErrors + } + x += s.limitWarnings + ImportPreview( + systemName = s.systemName, + headline = "Experimental. If something you need is listed as not supported, " + + "get in touch and bring your export.", + members = s.members.map { PreviewMember(it.id, it.name) }, + memberCount = s.memberCount, + categories = listOf( + ImportCategory( + key = "system_profile", + label = "System profile", + visible = s.systemName != null, + default = s.systemName != null, + ), + counted( + "templates", "Template members", s.templateCount, default = false, + note = "These count against your member limit. Only brought across when all members are selected.", + ), + counted("custom_fronts", "Custom statuses, as custom fronts", s.customFrontCount), + counted("folders", "Folders, as groups", s.folderCount), + counted("tags", "Tags", s.tagCount), + counted("custom_fields", "Custom fields", s.customFieldCount), + ImportCategory( + key = "front_history", + label = "Front history (${s.frontHistoryCount} entries)", + visible = s.frontHistoryCount > 0, + default = s.frontHistoryCount > 0, + note = if (s.frontingTypeCount > 0) "Fronting types are kept on each entry's status text." else null, + ), + ), + skipped = x, + ) + }, +) + /** Every source the Import screen can run, in the order the picker lists them. */ val importSources: List = listOf( simplyPlural, @@ -466,6 +523,7 @@ val importSources: List = listOf( prism, openPlural, ampersand, + berryTree, ) fun importSourceById(id: String?): ImportSource? = importSources.firstOrNull { it.id == id } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/imports/ImportHistoryViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/imports/ImportHistoryViewModel.kt index d3b9a7a..f82021c 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/imports/ImportHistoryViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/imports/ImportHistoryViewModel.kt @@ -103,6 +103,7 @@ internal fun importSourceLabel(source: String): String = when (source) { "tupperbox_file" -> "Tupperbox" "pluralspace_file" -> "PluralSpace" "prism_file" -> "Prism" + "berrytree_file" -> "BerryTree" else -> source } diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt index 6b1405c..ca7f4a0 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersScreen.kt @@ -810,6 +810,15 @@ fun MemberDetailScreen( ) { Text(level.replaceFirstChar { it.uppercase() }) } } } + val staged = state.member?.takeIf { it.pendingPrivacy != null && form.privacy == it.pendingPrivacy } + staged?.privacyActivatesAt?.let { at -> + Text( + "Staged: goes Public on ${formatRevisionDate(at, LocalDisplayTimeZone.current)}. " + + "Until then they stay ${staged.privacy}. Picking a lower level cancels it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } // Two ceilings that outrank any share view. They live here because // this is where the member is edited, not on the sharing screen. diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt index 986cd10..77437ec 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/members/MembersViewModel.kt @@ -422,7 +422,9 @@ class MemberDetailViewModel @Inject constructor( note = m.note ?: "", color = m.color ?: "#7F77DD", birthday = m.birthday ?: "", - privacy = m.privacy, + // A staged raise shows as the level it is heading for, + // so picking anything lower is a change that cancels it. + privacy = m.pendingPrivacy ?: m.privacy, avatarUrl = m.avatarUrl, bannerUrl = m.bannerUrl, neverShareable = m.neverShareable, @@ -507,6 +509,8 @@ class MemberDetailViewModel @Inject constructor( birthday = f.birthday.takeIf { it.isNotBlank() }, privacy = f.privacy, note = f.note.takeIf { it.isNotBlank() }, + password = password?.ifBlank { null }, + totpCode = totpCode?.ifBlank { null }, )).also { createdMemberId = it.id } } else { val update = MemberUpdate( @@ -524,7 +528,10 @@ class MemberDetailViewModel @Inject constructor( bannerUrl = f.bannerUrl, color = f.color.takeIf { it.isNotBlank() }, birthday = f.birthday.takeIf { it.isNotBlank() }, - privacy = f.privacy, + // Any level other than public counts as a lowering, + // which cancels a staged raise, so only send it when + // it was actually changed. + privacy = f.privacy.takeIf { it != _baselineForm.value.privacy }, // Empty string clears the column server-side; this lets // a user wipe a note that was previously set. note = f.note, diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt index 20e3a92..c876b41 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailScreen.kt @@ -189,6 +189,15 @@ fun ShareViewDetailScreen( ) SectionHeader("Members in this view") + if (view.includeAllPublicMembers) { + Text( + "This view shows everyone set to Public, so the list below is not being " + + "used. It is kept as it is, and comes back if you turn that off. " + + "Members marked never shareable are still never shown.", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } view.members.forEach { row -> val name = state.allMembers.find { it.id == row.memberId }?.name ?: "Unknown member" ListItem( @@ -431,6 +440,7 @@ internal fun previewNote( private fun ExposureFlag.liveValue(v: ShareViewRead): Boolean = when (this) { ExposureFlag.INCLUDE_MEMBERS -> v.includeMembers + ExposureFlag.INCLUDE_ALL_PUBLIC_MEMBERS -> v.includeAllPublicMembers ExposureFlag.INCLUDE_BIO -> v.includeBio ExposureFlag.INCLUDE_FRONTING -> v.includeFronting ExposureFlag.FRONTING_SHOW_COUNT -> v.frontingShowCount @@ -440,6 +450,7 @@ private fun ExposureFlag.liveValue(v: ShareViewRead): Boolean = when (this) { private fun ExposureFlag.pendingValue(v: ShareViewRead): Boolean? = when (this) { ExposureFlag.INCLUDE_MEMBERS -> v.pendingIncludeMembers + ExposureFlag.INCLUDE_ALL_PUBLIC_MEMBERS -> v.pendingIncludeAllPublicMembers ExposureFlag.INCLUDE_BIO -> v.pendingIncludeBio ExposureFlag.INCLUDE_FRONTING -> v.pendingIncludeFronting ExposureFlag.FRONTING_SHOW_COUNT -> v.pendingFrontingShowCount diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt index 2a31046..90d5949 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/ShareViewDetailViewModel.kt @@ -27,9 +27,13 @@ import systems.lupine.sheaf.data.model.ShareViewUpdate import systems.lupine.sheaf.util.toUserMessage import javax.inject.Inject -/** The six flags that widen what a view serves, and so carry a pending twin. */ +/** The flags that widen what a view serves, and so carry a pending twin. */ enum class ExposureFlag(val label: String, val supporting: String) { INCLUDE_MEMBERS("Show the roster", "The list of members in this view"), + INCLUDE_ALL_PUBLIC_MEMBERS( + "Show everyone set to Public", + "Follows each member's privacy instead of the list below, so someone you set to Public later appears without editing the view", + ), INCLUDE_BIO("Show bios", "Each member's description"), INCLUDE_FRONTING("Show who is fronting", "Live front state for members in this view"), FRONTING_SHOW_COUNT("Count hidden fronters", "Adds \"and N others not shown\""), @@ -207,6 +211,7 @@ class ShareViewDetailViewModel @Inject constructor( val body = when (r) { is ViewRaise.Flag -> when (r.flag) { ExposureFlag.INCLUDE_MEMBERS -> ShareViewUpdate(includeMembers = r.value) + ExposureFlag.INCLUDE_ALL_PUBLIC_MEMBERS -> ShareViewUpdate(includeAllPublicMembers = r.value) ExposureFlag.INCLUDE_BIO -> ShareViewUpdate(includeBio = r.value) ExposureFlag.INCLUDE_FRONTING -> ShareViewUpdate(includeFronting = r.value) ExposureFlag.FRONTING_SHOW_COUNT -> ShareViewUpdate(frontingShowCount = r.value) diff --git a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt index b200fbf..8608581 100644 --- a/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt +++ b/sheaf/app/src/main/java/systems/lupine/sheaf/ui/sharing/SharingScreen.kt @@ -329,6 +329,10 @@ private fun auditCounts(entry: ShareAuditEntry): String { val parts = mutableListOf() parts += if (!entry.includeMembers) { "${entry.memberCount} members curated, roster not shown" + } else if (entry.includeAllPublicMembers) { + // The curated rows are not the rule here, so "3 of 5" would compare + // against a list nobody is using. + "${entry.servedMemberCount ?: 0} members (everyone set to Public)" } else { val served = entry.servedMemberCount if (served != null && served != entry.memberCount) { diff --git a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/PatchJsonAdaptersTest.kt b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/PatchJsonAdaptersTest.kt index a4443e9..7b805d7 100644 --- a/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/PatchJsonAdaptersTest.kt +++ b/sheaf/app/src/test/java/systems/lupine/sheaf/data/api/PatchJsonAdaptersTest.kt @@ -104,6 +104,76 @@ class PatchJsonAdaptersTest { assertTrue("\"show_member_created_date\":false" in system(SystemUpdate(showMemberCreatedDate = false))) } + // ── Every field reaches the wire ────────────────────────────────────────── + + // A hand-written adapter only writes what it lists, so a field left off the + // list is dropped without a sound. That is how the ceilings and the step-up + // credentials went missing: re-auth never reached the server and the guard + // switches never saved. + + private fun assertAllKeys(json: String, keys: List) = + keys.forEach { assertTrue("\"$it\":" in json, "$it missing from: $json") } + + @Test fun `every member field is written when set`() { + val json = member( + MemberUpdate( + name = "A", displayName = "D", description = "d", pronouns = "p", + avatarUrl = "a", bannerUrl = "b", color = "#fff", birthday = "2000-01-01", + privacy = "public", note = "n", emoji = "x", + neverShareable = false, frontingPrivate = false, + password = "pw", totpCode = "123456", + ), + ) + assertAllKeys( + json, + listOf( + "name", "display_name", "description", "pronouns", "avatar_url", "banner_url", + "color", "birthday", "privacy", "note", "emoji", + "never_shareable", "fronting_private", "password", "totp_code", + ), + ) + assertTrue("\"never_shareable\":false" in json, json) + } + + @Test fun `every group field is written when set`() { + val json = group( + GroupUpdate( + name = "G", description = "d", color = "#fff", parentId = "p", + privacy = "public", password = "pw", totpCode = "123456", + ), + ) + assertAllKeys( + json, + listOf("name", "description", "color", "parent_id", "privacy", "password", "totp_code"), + ) + } + + @Test fun `every system field is written when set`() { + val json = system( + SystemUpdate( + name = "S", description = "d", tag = "t", avatarUrl = "a", color = "#fff", + privacy = "public", note = "n", showMemberCreatedDate = true, + password = "pw", totpCode = "123456", + ), + ) + assertAllKeys( + json, + listOf( + "name", "description", "tag", "avatar_url", "color", "privacy", "note", + "show_member_created_date", "password", "totp_code", + ), + ) + } + + @Test fun `unset ceilings, credentials and privacy are left out`() { + // All NOT NULL or step-up only: a null would fail the save or be noise. + listOf(member(MemberUpdate(name = "A")), group(GroupUpdate(name = "G")), system(SystemUpdate(name = "S"))) + .forEach { json -> + listOf("never_shareable", "fronting_private", "privacy", "password", "totp_code") + .forEach { assertFalse("\"$it\"" in json, "$it should be omitted: $json") } + } + } + @Test fun `the bodies stay valid JSON objects`() { listOf(member(MemberUpdate(name = "A")), group(GroupUpdate(name = "G")), system(SystemUpdate(name = "S"))) .forEach {