diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 8d17b0e5a3..5215446607 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1988,10 +1988,6 @@ Select People Members - - Send to %1$d Person - Send to %1$d People - 10 People Max The maximum number of people you can send to at once is 10 people diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 4680aa9b02..c7b8ff0be1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -490,10 +490,12 @@ private fun FlowGroupSelectPeople(viewModel: ChatViewModel, onSendComplete: () - val candidates = state.groupSendCandidates GroupSelectPeopleContent( candidates = candidates, - selected = groupSend.selected, + selected = groupSend.checked, onBack = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendBack) }, onToggle = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(it.userId)) }, onSend = { viewModel.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) }, + buttonEnabled = groupSend.selected.isNotEmpty(), + viewerId = groupSend.viewerId, ) if (groupSend.stage == GroupSendState.Stage.Confirm) { GroupSendConfirmSheet( diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 67cd3d42ca..36b28734f7 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -682,7 +682,7 @@ internal class ChatViewModel @AssistedInject constructor( val sendsToMembers: Boolean get() = chatType == ChatType.GROUP && (subject as? ChatSubject.Group)?.isPrivate == true - /** Who Select People lists: the roster, or what this device knows of it, less the viewer. */ + /** Who Select People lists: the roster, or what this device knows of it, the viewer first. */ val groupSendCandidates: List get() = groupSend?.candidates(members.ifEmpty { knownMembers }).orEmpty() @@ -4056,15 +4056,15 @@ internal class ChatViewModel @AssistedInject constructor( eventFlow.filterIsInstance() .onEach { (member) -> val groupSend = stateFlow.value.groupSend ?: return@onEach - val selected = groupSend.toggled(member) - if (selected == null) { + val toggled = groupSend.toggled(member) + if (toggled == null) { BottomBarManager.showInfo( title = resources.getString(R.string.title_groupSendMaxPeople), message = resources.getString(R.string.description_groupSendMaxPeople), ) return@onEach } - update { it.copy(selected = selected) } + update { toggled } }.launchIn(viewModelScope) eventFlow.filterIsInstance() @@ -4088,8 +4088,8 @@ internal class ChatViewModel @AssistedInject constructor( title = resources.getString(R.string.error_title_groupSendTooSmall), message = resources.getQuantityString( R.plurals.error_description_groupSendTooSmall, - groupSend.selected.size, - groupSend.selected.size, + groupSend.shares, + groupSend.shares, ), ) GroupSendPlan.NoRecipients, GroupSendPlan.TooManyRecipients -> Unit diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt index 1785a6ce33..ec8970950a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/groupsend/GroupSendState.kt @@ -11,31 +11,47 @@ import com.getcode.opencode.model.financial.Fiat * swipe. Node 11293:6918. * * @param total what the sender entered, in the currency it was entered in. Null until then. - * @param selected who to pay, in the order they were checked: the order decides the split. + * @param selected who to pay, in the order they were checked: the order decides the split. Never + * the sender, who is [includesSender] instead. + * @param includesSender whether the sender checked "You", splitting [total] with themselves and + * keeping their part. */ internal data class GroupSendState( val viewerId: ID?, val stage: Stage = Stage.Amount, val total: Fiat? = null, val selected: List = emptyList(), + val includesSender: Boolean = false, ) { enum class Stage { Amount, People, Confirm } /** The parts to pay, once [total] is known and someone is checked. */ val plan: GroupSendPlan - get() = total?.let { GroupSendSplit.split(GroupSendSplit.minorUnits(it), selected.size) } + get() = total?.let { GroupSendSplit.split(GroupSendSplit.minorUnits(it), selected.size, includesSender) } ?: GroupSendPlan.NoRecipients - /** Who Select People lists: the roster without the sender, who never pays themselves. */ + /** How many ways [total] splits: [selected], and the sender when [includesSender]. */ + val shares: Int get() = selected.size + if (includesSender) 1 else 0 + + /** The rows Select People shows checked: [selected], and the sender's when [includesSender]. */ + val checked: List get() = if (includesSender) selected + listOfNotNull(viewerId) else selected + + /** Who Select People lists: the sender first, as "You", then the rest of the roster. */ fun candidates(roster: List): List = - roster.filter { it.userId != viewerId }.distinctBy { it.userId } + roster.distinctBy { it.userId }.sortedByDescending { it.userId == viewerId } /** - * [selected] with [member] checked or unchecked, or null when checking them would make an 11th. + * This state with [member] checked or unchecked, the sender included, or null when checking + * them would make an 11th share. */ - fun toggled(member: ID): List? = when { - member in selected -> selected.filterNot { it == member } - selected.size >= GroupSendSplit.MAX_RECIPIENTS -> null - else -> selected.plusElement(member) + fun toggled(member: ID): GroupSendState? = when { + member == viewerId -> when { + includesSender -> copy(includesSender = false) + shares >= GroupSendSplit.MAX_RECIPIENTS -> null + else -> copy(includesSender = true) + } + member in selected -> copy(selected = selected.filterNot { it == member }) + shares >= GroupSendSplit.MAX_RECIPIENTS -> null + else -> copy(selected = selected.plusElement(member)) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt index 9b42cf5fc6..f588edf462 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/cash/GroupSendScreens.kt @@ -65,8 +65,9 @@ import com.getcode.ui.theme.CodeScaffold import com.getcode.view.LoadingSuccessState /** - * Select People, node 11293:6918: the group's members less the sender, checked one by one in the - * order that decides the split. There is no Select All. + * Select People, node 11293:6918: the group's members, checked one by one in the order that decides + * the split. There is no Select All; the sender's "You" row takes its place, as the first of + * [candidates] when [viewerId] is set. */ @Composable internal fun GroupSelectPeopleContent( @@ -75,7 +76,7 @@ internal fun GroupSelectPeopleContent( onBack: () -> Unit, onToggle: (ChatMember) -> Unit, onSend: () -> Unit, - buttonText: String = pluralStringResource(R.plurals.action_sendToPeople, selected.size, selected.size), + buttonText: String = stringResource(R.string.action_next), buttonEnabled: Boolean = selected.isNotEmpty(), /** Shown as "You" rather than by name, for a list the viewer is on. */ viewerId: ID? = null, @@ -196,8 +197,9 @@ internal fun GroupSendConfirmSheet( swipePay: SwipePayUiState, actions: SwipePayActions, ) { + // The total entered, not what is sent: with You checked, the sender's part stays behind. val total = groupSend.total ?: return - val parts = (groupSend.plan as? GroupSendPlan.Pay)?.parts ?: return + val plan = groupSend.plan as? GroupSendPlan.Pay ?: return val unknownName = stringResource(R.string.title_unnamedUser) SwipePaySheet( @@ -226,9 +228,9 @@ internal fun GroupSendConfirmSheet( style = CodeTheme.typography.displayMedium, color = CodeTheme.colors.textMain, ) - if (parts.size >= 2) { + if (plan.shares.size >= 2) { Text( - text = eachLabel(parts, total.currencyCode), + text = eachLabel(plan, total.currencyCode), style = CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) @@ -238,11 +240,14 @@ internal fun GroupSendConfirmSheet( ) } -/** "$1.00 Each" when the parts are equal, else "~$0.33 Each" for the smallest. */ +/** + * "$1.00 Each" when the shares are equal, else "~$0.33 Each" for the smallest. The sender's kept + * share counts: You + 1 on $5.00 is "$2.50 Each". + */ @Composable -private fun eachLabel(parts: List, currency: CurrencyCode): String { - val smallest = GroupSendSplit.fiat(parts.min(), currency).formatted() - return if (parts.distinct().size == 1) stringResource(R.string.label_groupSendEach, smallest) +private fun eachLabel(plan: GroupSendPlan.Pay, currency: CurrencyCode): String { + val smallest = GroupSendSplit.fiat(plan.smallest, currency).formatted() + return if (plan.isEven) stringResource(R.string.label_groupSendEach, smallest) else stringResource(R.string.label_groupSendEachApprox, smallest) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 0fa10d4bf2..e10572c8c7 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -382,7 +382,7 @@ class ChatGroupCashLinkTest { } @Test - fun `a private group's amount moves on to Select People without the viewer`() = runTest(mainCoroutineRule.dispatcher) { + fun `a private group's amount moves on to Select People with the viewer first`() = runTest(mainCoroutineRule.dispatcher) { val vm = createViewModel() selectingPeople(vm, total = 2.0) advanceUntilIdle() @@ -390,7 +390,8 @@ class ChatGroupCashLinkTest { val state = vm.stateFlow.value assertEquals(GroupSendState.Stage.People, state.groupSend?.stage) assertEquals(Fiat(2.0), state.groupSend?.total) - assertEquals(roster.map { it.userId }, state.groupSendCandidates.map { it.userId }) + assertEquals(listOf(viewerId) + roster.map { it.userId }, state.groupSendCandidates.map { it.userId }) + assertEquals(false, state.groupSend?.includesSender) coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } } @@ -444,4 +445,52 @@ class ChatGroupCashLinkTest { coVerify(exactly = 0) { chatCashLinks.sendToChat(any(), any(), any(), any()) } assertTrue(events.any { it is ChatViewModel.Event.SendComplete }) } + + @Test + fun `checking You splits with the sender, who keeps the last share`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 5.0) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(viewerId)) + vm.check(4) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendRequested) + advanceUntilIdle() + + coVerify(exactly = 1) { + groupSendPayments.send( + chatId = chatId, + recipients = roster.take(4).map { it.userId }, + plan = GroupSendPlan.Pay(listOf(100, 100, 100, 100), kept = 100), + currency = CurrencyCode.USD, + token = token, + source = any(), + ) + } + } + + @Test + fun `You counts toward the ten`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 20.0) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(viewerId)) + vm.check(10) + advanceUntilIdle() + + val groupSend = vm.stateFlow.value.groupSend + assertEquals(roster.take(9).map { it.userId }, groupSend?.selected) + assertEquals(true, groupSend?.includesSender) + assertEquals(1, BottomBarManager.messages.value.size) + } + + @Test + fun `You alone sends nothing`() = runTest(mainCoroutineRule.dispatcher) { + val vm = createViewModel() + selectingPeople(vm, total = 5.0) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendMemberToggled(viewerId)) + vm.dispatchEvent(ChatViewModel.Event.OnGroupSendPeopleConfirmed) + advanceUntilIdle() + + assertEquals(GroupSendState.Stage.People, vm.stateFlow.value.groupSend?.stage) + assertEquals(emptyList(), vm.stateFlow.value.groupSend?.selected) + } } diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt index bb556126a6..29190c2d10 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/group/GroupSendSplit.kt @@ -11,20 +11,29 @@ sealed interface GroupSendPlan { /** * Pay [parts], in the currency's minor unit and in selection order. One part is a single * payment; two or more are one multi-payment. + * + * @param kept the sender's own part when they split the total with themselves: the last share, + * never sent. Null when they didn't. */ - data class Pay(val parts: List) : GroupSendPlan { - val isEven: Boolean get() = parts.all { it == parts.first() } + data class Pay(val parts: List, val kept: Long? = null) : GroupSendPlan { + /** Every part of the total, the sender's included: what "Each" and its "~" describe. */ + val shares: List get() = parts + listOfNotNull(kept) + + /** What the intent pays: the total less [kept]. */ + val sent: Long get() = parts.sum() + + val isEven: Boolean get() = shares.all { it == shares.first() } /** The part shown as "Each": every part when [isEven], the smallest otherwise. */ - val smallest: Long get() = parts.min() + val smallest: Long get() = shares.min() } data object NoRecipients : GroupSendPlan - /** More than [GroupSendSplit.MAX_RECIPIENTS] were selected. */ + /** More than [GroupSendSplit.MAX_RECIPIENTS] shares, the sender's included, were selected. */ data object TooManyRecipients : GroupSendPlan - /** The total is under [GroupSendSplit.MINIMUM_PART_UNITS] per recipient. */ + /** The total is under [GroupSendSplit.MINIMUM_PART_UNITS] per share, the sender's included. */ data object PartBelowMinimum : GroupSendPlan } @@ -36,18 +45,30 @@ sealed interface GroupSendPlan { * part is the total divided by the recipient count, rounded down to that unit, and the leftover * goes one unit each to the first recipients in selection order, so the parts add up to the total * exactly. + * + * A sender who includes themselves takes the last share, so they never take a leftover unit, and + * keep it: the parts paid are the shares before it. The cap and the minimum count their share. */ object GroupSendSplit { const val MAX_RECIPIENTS = 10 const val MINIMUM_PART_UNITS = 1L - fun split(totalUnits: Long, recipients: Int): GroupSendPlan { + /** + * @param recipients the members paid, not counting the sender. + * @param includesSender whether the sender takes a share of [totalUnits] and keeps it. + */ + fun split(totalUnits: Long, recipients: Int, includesSender: Boolean = false): GroupSendPlan { if (recipients <= 0) return GroupSendPlan.NoRecipients - if (recipients > MAX_RECIPIENTS) return GroupSendPlan.TooManyRecipients - val base = totalUnits / recipients + val shares = if (includesSender) recipients + 1 else recipients + if (shares > MAX_RECIPIENTS) return GroupSendPlan.TooManyRecipients + val base = totalUnits / shares if (base < MINIMUM_PART_UNITS) return GroupSendPlan.PartBelowMinimum - val leftover = totalUnits % recipients - return GroupSendPlan.Pay(List(recipients) { i -> if (i < leftover) base + 1 else base }) + val leftover = totalUnits % shares + return GroupSendPlan.Pay( + parts = List(recipients) { i -> if (i < leftover) base + 1 else base }, + // The last share: the leftover never reaches it, since leftover < shares. + kept = base.takeIf { includesSender }, + ) } /** [fiat] in its currency's minor unit, rounded half-up to that unit. */ diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt index 2f0c5404a4..b5bfb8e434 100644 --- a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/group/GroupSendSplitVectorsTest.kt @@ -28,11 +28,12 @@ class GroupSendSplitVectorsTest { @Test fun `every vector`() { val vectors = fixture.getJSONArray("vectors") - assertEquals(14, vectors.length()) + assertEquals(23, vectors.length()) for (i in 0 until vectors.length()) { val v = vectors.getJSONObject(i) val name = v.getString("name") - val plan = GroupSendSplit.split(cents(v.getString("total")), v.getInt("recipients")) + val includesSender = v.optBoolean("includesSender", false) + val plan = GroupSendSplit.split(cents(v.getString("total")), v.getInt("recipients"), includesSender) if (v.has("expectedError")) { val error = when (val rule = v.getString("expectedError")) { @@ -51,14 +52,28 @@ class GroupSendSplitVectorsTest { val parts = v.getJSONArray("parts") assertEquals((0 until parts.length()).map { cents(parts.getString(it)) }, pay.parts, name) + assertEquals(v.optString("kept").ifEmpty { null }?.let(::cents), pay.kept, name) + if (includesSender) assertEquals(cents(v.getString("sent")), pay.sent, name) - if (v.has("eachLabel")) { - val each = "$" + BigDecimal.valueOf(pay.smallest, 2).toPlainString() + " Each" - assertEquals(v.getString("eachLabel"), if (pay.isEven) each else "~$each", name) - } + assertEquals(v.optString("eachLabel").ifEmpty { null }, pay.takeIf { it.shares.size > 1 }?.let(::eachLabel), name) } } + // Not in the fixture: the paid parts are equal but the sender's is a cent less, so "~" must + // come from the sender's share. 5 cents between You and 2 pays 2 and 2 and keeps 1. + @Test + fun `the sender's smaller share makes the label approximate`() { + val pay = assertIs(GroupSendSplit.split(5, 2, includesSender = true)) + assertEquals(listOf(2L, 2L), pay.parts) + assertEquals(1L, pay.kept) + assertEquals("~$0.01 Each", eachLabel(pay)) + } + + private fun eachLabel(pay: GroupSendPlan.Pay): String { + val each = "$" + BigDecimal.valueOf(pay.smallest, 2).toPlainString() + " Each" + return if (pay.isEven) each else "~$each" + } + @Test fun `minor units round trip through Fiat`() { assertEquals(12345L, GroupSendSplit.minorUnits(Fiat("123.45", CurrencyCode.USD))) diff --git a/test-vectors/group-send-split.json b/test-vectors/group-send-split.json index 00685790ca..748516c718 100644 --- a/test-vectors/group-send-split.json +++ b/test-vectors/group-send-split.json @@ -1,7 +1,7 @@ { "algorithm": "group-send-split", "source": "docs/specs/group-send.md (Splitting the total); flipcash2-client-protocol 0.21.0 CashContent.multi_receiver; ocp SendPublicMultiPaymentMetadata", - "note": "Amounts are USD decimal strings. parts are in selection order: the first recipients checked get the leftover cents. Quarks are omitted: each part is priced from its own fiat at one verified state. expectedError names the rule, not a platform error type.", + "note": "Amounts are USD decimal strings. parts are in selection order: the first recipients checked get the leftover cents. Quarks are omitted: each part is priced from its own fiat at one verified state. expectedError names the rule, not a platform error type. With includesSender, the sender's part is the last share, kept rather than paid: parts lists the paid members only, kept is the sender's part, sent is what the intent pays, and maxRecipients counts the sender's share.", "maxRecipients": 10, "minimumPart": "0.01", "vectors": [ @@ -176,6 +176,137 @@ "total": "1.00", "recipients": 0, "expectedError": "no-recipients" + }, + { + "name": "you and four", + "total": "5.00", + "recipients": 4, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "1.00", + "1.00", + "1.00", + "1.00" + ], + "kept": "1.00", + "sent": "4.00", + "eachLabel": "$1.00 Each" + }, + { + "name": "you and one", + "total": "5.00", + "recipients": 1, + "includesSender": true, + "intent": "SendPublicPayment", + "parts": [ + "2.50" + ], + "kept": "2.50", + "sent": "2.50", + "eachLabel": "$2.50 Each" + }, + { + "name": "you and two uneven", + "total": "1.00", + "recipients": 2, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.34", + "0.33" + ], + "kept": "0.33", + "sent": "0.67", + "eachLabel": "~$0.33 Each" + }, + { + "name": "you and three one cent left", + "total": "1.01", + "recipients": 3, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.26", + "0.25", + "0.25" + ], + "kept": "0.25", + "sent": "0.76", + "eachLabel": "~$0.25 Each" + }, + { + "name": "you and two equal parts, less kept", + "total": "0.05", + "recipients": 2, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.02", + "0.02" + ], + "kept": "0.01", + "sent": "0.04", + "eachLabel": "~$0.01 Each" + }, + { + "name": "you and five exact minimum", + "total": "0.06", + "recipients": 5, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "0.01", + "0.01", + "0.01", + "0.01", + "0.01" + ], + "kept": "0.01", + "sent": "0.05", + "eachLabel": "$0.01 Each" + }, + { + "name": "you and nine", + "total": "10.00", + "recipients": 9, + "includesSender": true, + "intent": "SendPublicMultiPayment", + "parts": [ + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00", + "1.00" + ], + "kept": "1.00", + "sent": "9.00", + "eachLabel": "$1.00 Each" + }, + { + "name": "you and ten", + "total": "11.00", + "recipients": 10, + "includesSender": true, + "expectedError": "too-many-recipients" + }, + { + "name": "too small with you", + "total": "0.02", + "recipients": 2, + "includesSender": true, + "expectedError": "part-below-minimum" + }, + { + "name": "you alone", + "total": "1.00", + "recipients": 0, + "includesSender": true, + "expectedError": "no-recipients" } ] }