feat(community): validate posts against NIP-9A community rules in composer

When the comment composer is replying into a NIP-72 community
(`replyingTo.event is CommunityDefinitionEvent`), subscribe to the
community's latest `kind:34551` rules document, run
`CommunityRulesValidator.validate(...)` on every draft change, render an
inline banner above the bottom action row when the draft would be
rejected, and disable the post button until the violation is resolved.

- `CommunityRulesFilterSubAssembler` is added to the existing
  `CommunityFilterAssembler.group` so any screen that already mounts
  `CommunityFilterAssemblerSubscription` (community feed, this composer)
  also pulls the latest rules event from the community's relays. Filter
  is `kinds=[34551]`, `authors=<owner+moderators>`, `#a=<addressTag>`,
  matching the NIP-72 trust model.
- `CommentPostViewModel` observes `kind:34551` via
  `LocalCache.observeEvents` keyed on the reply target, picks the latest
  `created_at` matching the community address, and re-runs the validator
  on every draft change. The validator's `postsTodayByKind` and `wot`
  callbacks are intentionally null for this PR (per-day quota lookups
  and NIP-02 follow-graph traversal are deferred to follow-ups; the
  validator skips those checks cleanly).
- Draft size is conservatively estimated from `content.toByteArray(UTF-8)`
  — tags add bytes, so this can under-count on the boundary, but relays
  still enforce the real cap. Good enough for a pre-send preview.
- `CommunityRulesViolationBanner` renders the first
  `CommunityRulesValidator.Violation` with a localized message; new
  strings cover all 7 sealed-violation variants.
- `CommentPostViewModelTest` covers valid drafts, kind-not-allowed,
  oversize, denied-author, the under-size boundary, and multibyte UTF-8
  size accounting.

Compose state — not StateFlow — backs `validationResult` and
`communityRules` so `canPost()` and the banner recompose without an
explicit `collectAsState` site at the top bar (`isActive` reads
`validationResult` directly).

Refs nostr-protocol/nips#2331
Closes #2759
This commit is contained in:
m
2026-05-09 08:51:28 +10:00
parent fbadc61070
commit cb0de81f1a
7 changed files with 475 additions and 1 deletions
@@ -71,6 +71,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
@@ -96,6 +97,8 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
import com.vitorpamplona.quartz.nip73ExternalIds.scope
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
@@ -113,6 +116,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@@ -151,6 +155,17 @@ open class CommentPostViewModel :
var notifying by mutableStateOf<List<User>?>(null)
// NIP-9A: latest community rules document for the community we're posting into.
// Null when the reply target is not a community, or no rules have been observed yet.
var communityRules: CommunityRulesEvent? by mutableStateOf(null)
private set
private var rulesObserverJob: Job? = null
// NIP-9A: latest validation outcome for the current draft.
// Null = no violation (or rules not yet known); non-null = first violation found.
var validationResult: CommunityRulesValidator.Violation? by mutableStateOf(null)
private set
override val message = TextFieldState()
val urlPreviews = PreviewState()
@@ -243,6 +258,90 @@ open class CommentPostViewModel :
open fun reply(post: Note) {
this.replyingTo = post
this.externalIdentity = (post.event as? CommentEvent)?.scope()
observeCommunityRules(post)
}
/**
* Subscribes to the latest [CommunityRulesEvent] when [target] is a NIP-72
* community, and clears the rules state otherwise. Re-evaluates the current
* draft against any rules that arrive (NIP-9A composer-side validation).
*/
private fun observeCommunityRules(target: Note) {
rulesObserverJob?.cancel()
rulesObserverJob = null
communityRules = null
validationResult = null
val targetEvent = target.event as? CommunityDefinitionEvent ?: return
val communityAddress = targetEvent.addressTag()
val authors = targetEvent.moderatorKeys()
val filter =
Filter(
kinds = listOf(CommunityRulesEvent.KIND),
authors = authors.sorted(),
tags = mapOf("a" to listOf(communityAddress)),
limit = 5,
)
rulesObserverJob =
viewModelScope.launch(Dispatchers.IO) {
LocalCache.observeEvents<CommunityRulesEvent>(filter).collectLatest { events ->
val latest =
events
.filter { it.communityAddress() == communityAddress }
.maxByOrNull { it.createdAt }
communityRules = latest
revalidateDraft()
}
}
}
/**
* Runs the NIP-9A validator against the current draft and updates
* [validationResult]. Web-of-trust gates and per-day quota checks are deferred
* to a follow-up (see NIP-9A track), so [postsTodayByKind] and `wot` are null
* here; the validator skips those checks cleanly.
*/
private fun revalidateDraft() {
val rules = communityRules
if (rules == null) {
validationResult = null
return
}
if (!::account.isInitialized) return
validationResult =
validateDraft(
rules = rules,
author = account.signer.pubKey,
draftContent = message.text.toString(),
)
}
companion object {
/**
* Pure helper that runs the NIP-9A validator for a `kind:1111` reply with
* [draftContent]. Sizes are estimated from the content bytes; tags add bytes,
* so this can under-count on the boundary, but relays still enforce the real
* cap. Good enough for a pre-send preview.
*
* Web-of-trust gates and per-day quota lookups are deferred to a follow-up;
* see the NIP-9A track in the issue tracker.
*/
internal fun validateDraft(
rules: CommunityRulesEvent,
author: String,
draftContent: String,
): CommunityRulesValidator.Violation? =
CommunityRulesValidator(rules).validate(
author = author,
kind = CommentEvent.KIND,
sizeBytes = draftContent.toByteArray(Charsets.UTF_8).size,
postsTodayByKind = null,
wot = null,
)
}
open fun quote(quote: Note) {
@@ -316,6 +415,7 @@ open class CommentPostViewModel :
replyingTo = LocalCache.getOrCreateNote(it)
}
}
replyingTo?.let { observeCommunityRules(it) }
wantsToAddGeoHash = draftEvent.hasGeohashes()
@@ -588,6 +688,11 @@ open class CommentPostViewModel :
message.setTextAndPlaceCursorAtEnd("")
rulesObserverJob?.cancel()
rulesObserverJob = null
communityRules = null
validationResult = null
replyingTo = null
externalIdentity = null
@@ -630,6 +735,7 @@ open class CommentPostViewModel :
override fun onMessageChanged() {
urlPreviews.update(message.text.toString())
revalidateDraft()
if (message.selection.collapsed) {
val lastWord = message.currentWord()
@@ -707,7 +813,8 @@ open class CommentPostViewModel :
!mediaUploadTracker.isUploading &&
!wantsInvoice &&
(!wantsZapraiser || zapRaiserAmount.value != null) &&
multiOrchestrator == null
multiOrchestrator == null &&
validationResult == null
fun insertAtCursor(newElement: String) {
message.insertUrlAtCursor(newElement)
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator
/**
* Inline banner shown above the composer's send button when the current draft
* violates the latest NIP-9A `kind:34551` rules document for the community
* being posted into. The send button is disabled in lockstep — see
* `CommentPostViewModel.canPost`.
*/
@Composable
fun CommunityRulesViolationBanner(violation: CommunityRulesValidator.Violation) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 6.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.errorContainer)
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Warning,
contentDescription = stringRes(R.string.community_rules_violation_icon),
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onErrorContainer,
)
Spacer(Modifier.width(10.dp))
Text(
text = describeViolation(violation),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
)
}
}
@Composable
private fun describeViolation(violation: CommunityRulesValidator.Violation): String =
when (violation) {
is CommunityRulesValidator.Violation.AuthorDenied -> {
stringRes(R.string.community_rules_violation_author_denied)
}
is CommunityRulesValidator.Violation.KindNotAllowed -> {
stringRes(R.string.community_rules_violation_kind_not_allowed, violation.kind)
}
is CommunityRulesValidator.Violation.KindSizeExceeded -> {
stringRes(
R.string.community_rules_violation_kind_size_exceeded,
violation.sizeBytes,
violation.maxBytes,
)
}
is CommunityRulesValidator.Violation.MaxSizeExceeded -> {
stringRes(
R.string.community_rules_violation_max_size_exceeded,
violation.sizeBytes,
violation.maxBytes,
)
}
is CommunityRulesValidator.Violation.QuotaExceeded -> {
stringRes(
R.string.community_rules_violation_quota_exceeded,
violation.postsToday,
violation.maxPerDay,
)
}
is CommunityRulesValidator.Violation.WotGateFailed -> {
stringRes(R.string.community_rules_violation_wot_gate_failed)
}
is CommunityRulesValidator.Violation.StaleRules -> {
stringRes(R.string.community_rules_violation_stale_rules)
}
}
@@ -52,6 +52,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
@@ -85,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssemblerSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size10dp
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
@@ -148,6 +150,16 @@ fun GenericCommentPostScreen(
) {
WatchAndLoadMyEmojiList(accountViewModel)
// NIP-9A: when replying into a NIP-72 community, mount the community feed
// subscription so the latest kind:34551 rules document is fetched and
// observed by the ViewModel for composer-side validation. No-op for
// hashtag/geohash composers (replyingTo is null or not addressable there).
(postViewModel.replyingTo as? AddressableNote)?.let { addressable ->
if (addressable.event is com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent) {
CommunityFilterAssemblerSubscription(addressable, accountViewModel.dataSources().community)
}
}
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
BackHandler {
@@ -411,6 +423,9 @@ private fun GenericCommentPostBody(
)
}
// NIP-9A: surface the first community-rules violation found in the current draft.
postViewModel.validationResult?.let { CommunityRulesViolationBanner(it) }
BottomRowActions(postViewModel)
}
}
@@ -35,6 +35,7 @@ class CommunityFilterAssembler(
val group =
listOf(
CommunityFeedFilterSubAssembler(client, ::allKeys),
CommunityRulesFilterSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
@@ -0,0 +1,80 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent
/**
* Subscribes to the latest NIP-9A [CommunityRulesEvent] (kind:34551) for each
* tracked community.
*
* Reuses the existing [CommunityQueryState] keyspace so any screen that already
* subscribes to the community feed (via [CommunityFilterAssemblerSubscription])
* also pulls the rules document. Only events signed by the community owner or a
* declared moderator are accepted, matching the trust model of NIP-72 approvals.
*/
class CommunityRulesFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<CommunityQueryState>,
) : SingleSubEoseManager<CommunityQueryState>(client, allKeys) {
override fun updateFilter(
keys: List<CommunityQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (keys.isEmpty()) return emptyList()
return keys.flatMap { key ->
val commEvent = key.community.event
if (commEvent !is CommunityDefinitionEvent) return@flatMap emptyList()
val signers = commEvent.moderatorKeys()
val communityRelays =
(
commEvent.relayUrls().ifEmpty {
LocalCache.relayHints.hintsForAddress(commEvent.addressTag())
} + key.community.relayUrls()
).toSet()
communityRelays.map { relay ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = listOf(CommunityRulesEvent.KIND),
authors = signers.sorted(),
tags = mapOf("a" to listOf(commEvent.addressTag())),
limit = 5,
since = since?.get(relay)?.time,
),
)
}
}
}
override fun distinct(key: CommunityQueryState) = key.community.idHex
}
+9
View File
@@ -2772,4 +2772,13 @@
<string name="emoji_public_explainer">Public emojis are visible to everyone and appear in your reaction menu and \":\" autocomplete picker when this pack is in your emoji list.</string>
<string name="emoji_private_explainer">Private emojis are stored encrypted on relays and visible only to you. They appear in your reaction menu and \":\" autocomplete just like public ones.</string>
<string name="gif">Gif</string>
<!-- NIP-9A community rules composer-side validation. -->
<string name="community_rules_violation_icon">Community rule violation</string>
<string name="community_rules_violation_author_denied">You are on this community\'s deny-list. Posts will not be accepted.</string>
<string name="community_rules_violation_kind_not_allowed">This community does not accept events of kind %1$d.</string>
<string name="community_rules_violation_kind_size_exceeded">Draft is %1$d bytes; this community caps this kind at %2$d bytes.</string>
<string name="community_rules_violation_max_size_exceeded">Draft is %1$d bytes; this community caps events at %2$d bytes.</string>
<string name="community_rules_violation_quota_exceeded">You have already posted %1$d events of this kind today (limit %2$d).</string>
<string name="community_rules_violation_wot_gate_failed">You don\'t pass this community\'s web-of-trust requirements.</string>
<string name="community_rules_violation_stale_rules">The latest community rules document was rejected as stale.</string>
</resources>
@@ -0,0 +1,143 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note.nip22Comments
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for the pure NIP-9A draft-validation helper exposed on
* [CommentPostViewModel]'s companion. The ViewModel itself is too coupled to
* Compose state, the relay subscription stack, and signers to construct in a
* unit test, but the helper is the only place where draft size, kind, and
* author flow into the validator so testing it covers what matters.
*/
class CommentPostViewModelTest {
private val community = "a".repeat(64)
private val author = "b".repeat(64)
private val denied = "c".repeat(64)
private fun rules(
allowedKind: Int = CommentEvent.KIND,
maxBytes: Int? = null,
deny: List<String> = emptyList(),
): CommunityRulesEvent {
val tags =
buildList<Array<String>> {
add(arrayOf("d", "rules-1"))
add(arrayOf("a", "34550:$community:my-community"))
if (maxBytes != null) {
add(arrayOf("k", allowedKind.toString(), maxBytes.toString()))
} else {
add(arrayOf("k", allowedKind.toString()))
}
deny.forEach { add(arrayOf("p", it, "deny")) }
}.toTypedArray()
return CommunityRulesEvent(
id = "0".repeat(64),
pubKey = community,
createdAt = 100L,
tags = tags,
content = "",
sig = "0".repeat(128),
)
}
@Test
fun `valid draft passes without violation`() {
val violation =
CommentPostViewModel.validateDraft(
rules = rules(),
author = author,
draftContent = "hello world",
)
assertNull(violation)
}
@Test
fun `kind not allowed yields KindNotAllowed`() {
val violation =
CommentPostViewModel.validateDraft(
rules = rules(allowedKind = 30023), // long-form, not kind:1111
author = author,
draftContent = "hello",
)
assertTrue(violation is CommunityRulesValidator.Violation.KindNotAllowed)
assertEquals(CommentEvent.KIND, (violation as CommunityRulesValidator.Violation.KindNotAllowed).kind)
}
@Test
fun `oversize draft yields KindSizeExceeded`() {
val draft = "x".repeat(120)
val violation =
CommentPostViewModel.validateDraft(
rules = rules(maxBytes = 100),
author = author,
draftContent = draft,
)
assertTrue(violation is CommunityRulesValidator.Violation.KindSizeExceeded)
val ks = violation as CommunityRulesValidator.Violation.KindSizeExceeded
assertEquals(120, ks.sizeBytes)
assertEquals(100, ks.maxBytes)
}
@Test
fun `denied author yields AuthorDenied regardless of content`() {
val violation =
CommentPostViewModel.validateDraft(
rules = rules(deny = listOf(denied)),
author = denied,
draftContent = "hello",
)
assertTrue(violation is CommunityRulesValidator.Violation.AuthorDenied)
}
@Test
fun `under-size boundary passes`() {
val draft = "x".repeat(100) // exactly the cap
val violation =
CommentPostViewModel.validateDraft(
rules = rules(maxBytes = 100),
author = author,
draftContent = draft,
)
assertNull(violation)
}
@Test
fun `multibyte chars count toward size cap by bytes not chars`() {
// "🚀" is 4 UTF-8 bytes, so 30 of them = 120 bytes > cap of 100.
val draft = "🚀".repeat(30)
val violation =
CommentPostViewModel.validateDraft(
rules = rules(maxBytes = 100),
author = author,
draftContent = draft,
)
assertTrue(violation is CommunityRulesValidator.Violation.KindSizeExceeded)
assertEquals(120, (violation as CommunityRulesValidator.Violation.KindSizeExceeded).sizeBytes)
}
}