test: cover NIP-29 group-chat filter shapes and can't-miss history paging

Extract the pure REQ-filter construction out of the NIP-29 group-chat
assemblers into RelayGroupFilterBuilders so the exact filter each screen
puts on the wire can be unit-tested without an Account or relay client,
and point every assembler at the shared builders (dropping the duplicated
per-file kind lists).

Add two test suites from the branch's test plan:

- RelayGroupFilterBuildersTest (Tier B): pins the kinds, #d/#h scope,
  per-host-relay batching, since/until/limit and all-authors shape of the
  state, joined chat-tail, open chat-tail, history-pager, threads and
  card-warmup joined-skip filters.

- RelayGroupHistoryPagingRelayTest (Tier C3/E1): the can't-miss-messages
  property against the in-process geode relay -- a backward #h + kind-9
  walk delivers every group message exactly once and stops on an empty
  page, and an #h-scoped walk isolates one group from another on the same
  relay even when their createdAt ranges fully overlap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
This commit is contained in:
Claude
2026-07-18 17:13:56 +00:00
parent 4cf86ca030
commit 399bef6bf7
10 changed files with 512 additions and 122 deletions
@@ -4,6 +4,12 @@
state-vs-content refactor (see `2026-07-18-nip29-group-chat-subscriptions.md`).
**Question this answers:** *does the correct data load on every screen, and can we ever miss a message?*
**Implemented on this branch so far:**
- **Tier B** (filter shapes, assemblers 16 + card-warmup joined-skip): `amethyst/src/test/.../relayGroup/datasource/RelayGroupFilterBuildersTest.kt`, testing the pure `RelayGroupFilterBuilders.kt` the assemblers now delegate to.
- **Tier C3 / E1** (can't-miss backward `#h` walk + same-relay group isolation, against the in-process `geode` relay): `quartz/src/jvmAndroidTest/.../paging/RelayGroupHistoryPagingRelayTest.kt`.
Still to do: the remaining Tier B rows (710, reconnect stability), the wider Tier C `amy` integration set, Tier D device runs, and Tier E2 real-relay conformance.
## What is / isn't verifiable headless
| Layer | Harness | Covers |
@@ -29,13 +29,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.dataso
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.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/** One on-screen group card's request to warm a single group. */
class RelayGroupCardWarmupQueryState(
@@ -47,10 +42,6 @@ class RelayGroupCardWarmupQueryState(
val contentLimit: Int = RELAY_GROUP_WARMUP_LIMIT,
)
/** Newest content kinds we prefetch so opening the card lands on populated screens. */
private val RELAY_GROUP_WARMUP_CONTENT_KINDS =
listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND)
/**
* Default number of recent events to pull ahead of a tap — enough to fill the first screen AND drive
* the discovery card's "50+ messages" activity signal (a chat that returns the full page reads as
@@ -95,8 +86,7 @@ class RelayGroupCardWarmupSubAssembler(
// groups shown as cards that those don't cover — above all NON-joined groups (discovery, a relay's
// channel list, member/metadata/parent screens). So skip a group we've already joined to avoid
// re-fetching what's live everywhere. See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.
val joined = key.account.relayGroupList.liveRelayGroupList.value
if (joined.any { it.groupId == groupId.id && RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == groupId.relayUrl }) {
if (isRelayGroupJoined(key.account.relayGroupList.liveRelayGroupList.value, groupId)) {
return emptyList()
}
val metadata = if (key.contentOnly) emptyList() else filterRelayGroupState(key.channel, since)
@@ -105,7 +95,7 @@ class RelayGroupCardWarmupSubAssembler(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_WARMUP_CONTENT_KINDS,
kinds = RELAY_GROUP_CARD_WARMUP_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
limit = key.contentLimit,
since = since?.get(groupId.relayUrl)?.time,
@@ -0,0 +1,185 @@
/*
* 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.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
/*
* Pure REQ-filter builders for the NIP-29 group-chat data sources. Kept separate from the assemblers so
* the exact filter each screen puts on the wire (kinds, #d/#h scope, per-relay batching, since/until/limit,
* authors) can be unit-tested without standing up an Account or relay client.
*
* See amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md and the companion test plan.
*/
/** Relay-signed group *state*: metadata + admins + members + roles + pins. Small replaceable events. */
val RELAY_GROUP_STATE_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
GroupPinnedEvent.KIND,
)
/** Timeline kinds shown in a group's chat — chat messages and polls. */
val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/** Forum-thread kinds shown in a group's Threads tab. */
val RELAY_GROUP_THREAD_KINDS = listOf(ThreadEvent.KIND, CommentEvent.KIND)
/** Content kinds a card warms ahead of a tap (chat + polls + threads + comments). */
val RELAY_GROUP_CARD_WARMUP_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND, ThreadEvent.KIND, CommentEvent.KIND)
/** `d`-tag key of the relay-signed state events (39xxx are addressable by the group id). */
private const val D_TAG = "d"
private fun byHostRelay(joined: Collection<GroupTag>): Map<NormalizedRelayUrl, List<String>> {
val out = LinkedHashMap<NormalizedRelayUrl, MutableList<String>>()
joined.forEach { tag ->
val relay = RelayUrlNormalizer.normalizeOrNull(tag.relayUrl) ?: return@forEach
out.getOrPut(relay) { mutableListOf() }.add(tag.groupId)
}
return out
}
/**
* State (39000-39005) for every joined group, **one `#d` filter per host relay** carrying that relay's
* group ids. `since` is per-relay (replaceable events; a reconnect just re-confirms).
*/
fun buildRelayGroupStateFilters(
joined: Collection<GroupTag>,
sinceForRelay: (NormalizedRelayUrl) -> Long?,
): List<RelayBasedFilter> =
byHostRelay(joined).map { (relay, ids) ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_STATE_KINDS,
tags = mapOf(D_TAG to ids.distinct()),
since = sinceForRelay(relay),
),
)
}
/**
* Recent chat of every joined group, **one `#h` filter per host relay** carrying that relay's group ids,
* bounded by a shared time floor ([sinceEpoch]) and **no per-group `limit`** — this is what lets the whole
* relay's groups batch into a single REQ and makes it reconnect-safe.
*/
fun buildRelayGroupJoinedChatTailFilters(
joined: Collection<GroupTag>,
sinceEpoch: Long,
): List<RelayBasedFilter> =
byHostRelay(joined).map { (relay, ids) ->
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to ids.distinct()),
since = sinceEpoch,
),
)
}
/** The recent-chat live tail for a single open group, `#h`-scoped on its host relay. */
fun buildRelayGroupOpenChatTailFilter(
groupId: GroupId,
sinceEpoch: Long,
): RelayBasedFilter =
RelayBasedFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),
)
/**
* Backward-history page(s) for a single open group: one `#h` filter per **armed** relay at its own
* `until`, capped by [limit], **all authors** (so it also re-materializes the user's own history). A
* relay with no requested `until` contributes nothing (it is parked).
*/
fun buildRelayGroupHistoryFilters(
groupId: GroupId,
armedRelays: Collection<NormalizedRelayUrl>,
untilForRelay: (NormalizedRelayUrl) -> Long?,
limit: Int,
): List<RelayBasedFilter> =
armedRelays.mapNotNull { relay ->
val until = untilForRelay(relay) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
until = until,
limit = limit,
),
)
}
/** The Threads-tab feed for a single open group: kind-11/1111 `#h`-scoped on the host relay. */
fun buildRelayGroupThreadsFilter(
groupId: GroupId,
sinceEpoch: Long?,
): RelayBasedFilter =
RelayBasedFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = RELAY_GROUP_THREAD_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId.id)),
since = sinceEpoch,
),
)
/**
* Whether [groupId] is in the user's joined set — a joined group is kept warm app-wide by the always-on
* state + chat-tail subs, so the on-screen [RelayGroupCardWarmupFilterAssembler] must skip it.
*/
fun isRelayGroupJoined(
joined: Collection<GroupTag>,
groupId: GroupId,
): Boolean =
joined.any {
it.groupId == groupId.id && RelayUrlNormalizer.normalizeOrNull(it.relayUrl) == groupId.relayUrl
}
@@ -32,19 +32,11 @@ 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.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
/** Timeline kinds shown in a group's chat — chat messages and polls. */
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/** One screen's request to keep the user's joined groups' recent chat live. */
class RelayGroupJoinedChatTailQueryState(
val account: Account,
@@ -102,22 +94,7 @@ class RelayGroupJoinedChatTailSubAssembler(
// One #h filter per host relay carrying every joined group id on it; bounded by the shared
// recent-tail floor, so no per-group limit and no per-group re-subscribe on join.
val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId })
val sinceTime = DmHistoryTuning.recentBoundary()
val filters =
idsByRelay.mapNotNull { (relayUrl, groupIds) ->
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to groupIds.distinct()),
since = sinceTime,
),
)
}
val filters = buildRelayGroupJoinedChatTailFilters(joined, DmHistoryTuning.recentBoundary())
windowLoad.setExpectedRelays(filters.mapTo(mutableSetOf()) { it.relay })
return filters
}
@@ -29,28 +29,8 @@ 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.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupAdminsEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMembersEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.SupportedRolesEvent
import kotlinx.coroutines.Job
/**
* Relay-signed group *state*: metadata (39000) + admins (39001) + members (39002) + supported roles
* (39003) + pins (39005). Small replaceable/addressable events — one per kind per group.
*/
private val RELAY_GROUP_STATE_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
GroupPinnedEvent.KIND,
)
/** One request to keep the relay-signed state of the user's joined groups live. */
class RelayGroupJoinedStateQueryState(
val account: Account,
@@ -93,19 +73,7 @@ class RelayGroupJoinedStateSubAssembler(
val joined = key.account.relayGroupList.liveRelayGroupList.value
if (joined.isEmpty()) return null
val idsByRelay = joined.groupBy({ it.relayUrl }, { it.groupId })
return idsByRelay.mapNotNull { (relayUrl, groupIds) ->
val relay = RelayUrlNormalizer.normalizeOrNull(relayUrl) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_STATE_KINDS,
tags = mapOf("d" to groupIds.distinct()),
since = since?.get(relay)?.time,
),
)
}
return buildRelayGroupStateFilters(joined) { since?.get(it)?.time }
}
override fun id(key: RelayGroupJoinedStateQueryState) = key.account
@@ -35,15 +35,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscriptio
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.StateFlow
/** Timeline kinds paged in a NIP-29 group's chat — chat messages and polls. */
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/** One open NIP-29 group whose older history the chat screen wants paged in. */
class RelayGroupOpenChatHistoryQueryState(
val account: Account,
@@ -106,19 +100,7 @@ class RelayGroupOpenChatHistorySubAssembler(
// relay keeps no filter here, so re-assembly (a marker advancing) doesn't re-REQ a settled window.
val armed = pager.armedRelays(relays)
if (armed.isEmpty()) return emptyList()
return armed.mapNotNull { relay ->
val until = pager.requestedUntilFor(relay) ?: return@mapNotNull null
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(key.groupId.id)),
until = until,
limit = pager.pageLimit,
),
)
}
return buildRelayGroupHistoryFilters(key.groupId, armed, { pager.requestedUntilFor(it) }, pager.pageLimit)
}
/** Steps a single [relay] to its next, older page for the open group. Driven by its on-screen marker. */
@@ -30,18 +30,11 @@ 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.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.StateFlow
/** Timeline kinds shown in a group's chat — chat messages and polls. */
private val RELAY_GROUP_TIMELINE_KINDS = listOf(ChatEvent.KIND, PollEvent.KIND)
/** One open NIP-29 group whose recent chat the screen wants live. */
class RelayGroupOpenChatTailQueryState(
val account: Account,
@@ -82,19 +75,8 @@ class RelayGroupOpenChatTailSubAssembler(
key: RelayGroupOpenChatTailQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val relay = key.groupId.relayUrl
windowLoad.setExpectedRelays(setOf(relay))
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_TIMELINE_KINDS,
tags = mapOf(GroupIdTag.TAG_NAME to listOf(key.groupId.id)),
since = DmHistoryTuning.recentBoundary(),
),
),
)
windowLoad.setExpectedRelays(setOf(key.groupId.relayUrl))
return listOf(buildRelayGroupOpenChatTailFilter(key.groupId, DmHistoryTuning.recentBoundary()))
}
override fun id(key: RelayGroupOpenChatTailQueryState) = key.groupId
@@ -26,10 +26,7 @@ import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEo
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.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
/** One threads-screen's request for a single group's kind-11 threads. */
class RelayGroupOpenThreadsQueryState(
@@ -67,17 +64,7 @@ class RelayGroupOpenThreadsSubAssembler(
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val groupId = key.channel.groupId
return listOf(
RelayBasedFilter(
relay = groupId.relayUrl,
filter =
Filter(
kinds = listOf(ThreadEvent.KIND, CommentEvent.KIND),
tags = mapOf("h" to listOf(groupId.id)),
since = since?.get(groupId.relayUrl)?.time,
),
),
)
return listOf(buildRelayGroupThreadsFilter(groupId, since?.get(groupId.relayUrl)?.time))
}
override fun id(key: RelayGroupOpenThreadsQueryState) = key.channel.groupId
@@ -0,0 +1,160 @@
/*
* 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.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip51Lists.simpleGroupList.GroupTag
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the exact REQ each NIP-29 data source puts on the wire (kinds, `#d`/`#h` scope, per-relay
* batching, `since`/`until`/`limit`, authors) — the "does the correct query load per screen" half of
* amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md (Tier B), without an Account/relay client.
*/
class RelayGroupFilterBuildersTest {
private val relayAUrl = "wss://relay-a.example/"
private val relayBUrl = "wss://relay-b.example/"
private val relayA = RelayUrlNormalizer.normalizeOrNull(relayAUrl)!!
private val relayB = RelayUrlNormalizer.normalizeOrNull(relayBUrl)!!
// Joined set: two groups on relay A, one on relay B.
private val joined =
setOf(
GroupTag("g1", relayAUrl),
GroupTag("g2", relayAUrl),
GroupTag("g3", relayBUrl),
)
private val g1OnA = GroupId("g1", relayA)
private val timelineKinds = listOf(ChatEvent.KIND, PollEvent.KIND)
private val threadKinds = listOf(ThreadEvent.KIND, CommentEvent.KIND)
// --- State (always-on): one #d filter per host relay, batching that relay's group ids ---
@Test
fun `state batches one d-filter per host relay`() {
val filters = buildRelayGroupStateFilters(joined) { null }
assertEquals(2, filters.size)
val a = filters.single { it.relay == relayA }
assertEquals(RELAY_GROUP_STATE_KINDS, a.filter.kinds)
assertEquals(setOf("g1", "g2"), a.filter.tags!!["d"]!!.toSet())
assertNull("state is #d-scoped, never #h", a.filter.tags!!["h"])
val b = filters.single { it.relay == relayB }
assertEquals(listOf("g3"), b.filter.tags!!["d"])
}
@Test
fun `state applies the per-relay since`() {
val filters = buildRelayGroupStateFilters(joined) { relay -> if (relay == relayA) 111L else null }
assertEquals(111L, filters.single { it.relay == relayA }.filter.since)
assertNull(filters.single { it.relay == relayB }.filter.since)
}
// --- Joined chat tail (always-on): batched #h per relay, time floor, NO per-group limit ---
@Test
fun `joined chat tail batches one h-filter per relay with no count limit`() {
val filters = buildRelayGroupJoinedChatTailFilters(joined, 999L)
assertEquals(2, filters.size)
val a = filters.single { it.relay == relayA }
assertEquals(timelineKinds, a.filter.kinds)
assertEquals(setOf("g1", "g2"), a.filter.tags!!["h"]!!.toSet())
assertEquals(999L, a.filter.since)
assertNull("a time-floored tail must NOT cap by count (that is what lets it batch)", a.filter.limit)
assertNull(a.filter.until)
}
// --- Open chat tail: a single group's recent #h on the host relay ---
@Test
fun `open chat tail is a single host h-filter with since only`() {
val f = buildRelayGroupOpenChatTailFilter(g1OnA, 5L)
assertEquals(relayA, f.relay)
assertEquals(timelineKinds, f.filter.kinds)
assertEquals(listOf("g1"), f.filter.tags!!["h"])
assertEquals(5L, f.filter.since)
assertNull(f.filter.until)
assertNull(f.filter.limit)
}
// --- Open chat history (backward pager): only armed relays, each at its own until, all authors ---
@Test
fun `history emits only armed relays at their until, all authors`() {
val untilByRelay = mapOf(relayA to 200L) // relayB not armed → no cursor
val filters = buildRelayGroupHistoryFilters(g1OnA, listOf(relayA, relayB), { untilByRelay[it] }, 50)
val f = filters.single()
assertEquals(relayA, f.relay)
assertEquals(200L, f.filter.until)
assertEquals(50, f.filter.limit)
assertNull("history must be all-authors so it re-materializes my own messages too", f.filter.authors)
assertNull("backward paging is until-anchored, not since", f.filter.since)
assertEquals(listOf("g1"), f.filter.tags!!["h"])
}
@Test
fun `history with nothing armed builds no filters`() {
assertTrue(buildRelayGroupHistoryFilters(g1OnA, emptyList(), { 1L }, 50).isEmpty())
}
// --- Threads tab: kind 11/1111 #h on the host relay ---
@Test
fun `threads filter is host-scoped thread kinds`() {
val f = buildRelayGroupThreadsFilter(g1OnA, 7L)
assertEquals(relayA, f.relay)
assertEquals(threadKinds, f.filter.kinds)
assertEquals(listOf("g1"), f.filter.tags!!["h"])
assertEquals(7L, f.filter.since)
}
// --- Card warmup joined-skip: joined groups are covered always-on, so warmup must skip them ---
@Test
fun `joined check keys on both group id and host relay`() {
assertTrue(isRelayGroupJoined(joined, GroupId("g1", relayA)))
// Same group id but a DIFFERENT host relay is a different group — must not count as joined.
assertFalse(isRelayGroupJoined(joined, GroupId("g1", relayB)))
assertFalse(isRelayGroupJoined(joined, GroupId("gX", relayA)))
assertFalse(isRelayGroupJoined(emptySet(), GroupId("g1", relayA)))
}
// --- Empty joined set ---
@Test
fun `empty joined set produces no filters`() {
assertTrue(buildRelayGroupStateFilters(emptySet()) { null }.isEmpty())
assertTrue(buildRelayGroupJoinedChatTailFilters(emptySet(), 1L).isEmpty())
}
}
@@ -0,0 +1,153 @@
/*
* 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.quartz.nip01Core.relay.client.paging
import com.vitorpamplona.geode.fixtures.SyntheticEvents
import com.vitorpamplona.geode.testing.RelayClientTest
import com.vitorpamplona.geode.testing.collectUntilEose
import com.vitorpamplona.geode.testing.preload
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The **can't-miss-messages** property of the NIP-29 group-history pager, proven against the in-process
* relay rather than a mocked filter. The `RelayGroupOpenChatHistorySubAssembler` walks a group's chat
* backward with an `#h`-scoped `until`+`limit` REQ; this pins the two relay-side guarantees that whole
* design rests on:
*
* 1. A backward `#h`+kind-9 walk over one group delivers **every** message **exactly once** and stops
* cleanly on an empty page — no gap, no re-download (the same contract as
* [UntilLimitPagingRelayTest], but with the group's `h`-tag on the wire).
* 2. An `#h`-scoped walk **isolates one group from another on the same relay**, even when their
* `createdAt` ranges fully overlap — so paging one group can never surface, or be blocked by,
* another group's messages. This is the guarantee that a serving relay hosting many groups can't
* leak or hide messages across the `h`-tag boundary.
*/
class RelayGroupHistoryPagingRelayTest : RelayClientTest() {
/** One group's kind-9 chat: distinct ids, monotonic createdAt from 1, all carrying `#h = groupId`. */
private fun groupChat(
idBase: Int,
count: Int,
groupId: String,
): List<Event> =
List(count) { i ->
SyntheticEvents.fakeEvent(
idSeed = idBase + i,
kind = ChatEvent.KIND,
createdAt = (i + 1).toLong(),
tags = arrayOf(arrayOf(GroupIdTag.TAG_NAME, groupId)),
)
}
private fun hFilter(
groupId: String,
until: Long?,
) = Filter(
kinds = listOf(ChatEvent.KIND),
tags = mapOf(GroupIdTag.TAG_NAME to listOf(groupId)),
until = until,
limit = LIMIT,
)
@Test
fun backwardHTagWalkCoversEveryGroupMessageOnceAndStopsOnEmptyPage() =
runBlocking {
defaultRelay.preload(groupChat(idBase = 1, count = TOTAL, groupId = "g1"))
// A second group on the same relay must never leak into g1's walk.
defaultRelay.preload(groupChat(idBase = 1_000_000, count = 77, groupId = "g2"))
val seenIds = mutableSetOf<String>()
var totalReceived = 0
var pages = 0
var until: Long? = null
while (pages < SAFETY_CAP) {
val (events, eose) = client.collectUntilEose(defaultRelayUrl, hFilter("g1", until))
assertTrue(eose, "every page must end with EOSE")
if (events.isEmpty()) break // gap-proof stop: empty page = nothing older
pages++
assertTrue(events.size <= LIMIT, "page must respect the limit")
assertTrue(events.all { it.isTaggedGroup("g1") }, "an #h=g1 REQ must return only g1 messages")
until?.let { cursor -> assertTrue(events.all { it.createdAt <= cursor }, "page must be older than the cursor") }
events.forEach { e ->
seenIds.add(e.id)
totalReceived++
}
until = events.minOf { it.createdAt } - 1
}
assertEquals(TOTAL, totalReceived, "no message delivered twice across pages")
assertEquals(TOTAL, seenIds.size, "every group message fetched exactly once")
// 250 / 100 → 100 + 100 + 50, then an empty page stops the walk.
assertEquals(3, pages)
}
@Test
fun hTagScopedWalkIsolatesGroupsWithOverlappingTimeRangesOnTheSameRelay() =
runBlocking {
// Two groups on ONE relay, createdAt ranges deliberately OVERLAPPING (both start at 1) so the
// only thing that can separate them is the #h tag, never time.
defaultRelay.preload(groupChat(idBase = 1, count = 40, groupId = "gA"))
defaultRelay.preload(groupChat(idBase = 1_000, count = 60, groupId = "gB"))
val a = walkGroup("gA")
val b = walkGroup("gB")
assertEquals(40, a.size, "the #h=gA walk sees exactly gA's messages")
assertEquals(60, b.size, "the #h=gB walk sees exactly gB's messages")
assertTrue(a.all { it.isTaggedGroup("gA") }, "gA walk must not surface any gB message")
assertTrue(b.all { it.isTaggedGroup("gB") }, "gB walk must not surface any gA message")
assertTrue(a.map { it.id }.intersect(b.map { it.id }.toSet()).isEmpty(), "no message id belongs to both groups")
}
/** Full backward `#h`-scoped drain for one group; returns the distinct events it delivered. */
private suspend fun walkGroup(groupId: String): List<Event> {
val out = mutableMapOf<String, Event>()
var until: Long? = null
var pages = 0
while (pages < SAFETY_CAP) {
val (events, eose) = client.collectUntilEose(defaultRelayUrl, hFilter(groupId, until))
assertTrue(eose)
if (events.isEmpty()) break
pages++
events.forEach { out[it.id] = it }
until = events.minOf { it.createdAt } - 1
}
return out.values.toList()
}
private fun Event.isTaggedGroup(groupId: String) = tags.any { it.size >= 2 && it[0] == GroupIdTag.TAG_NAME && it[1] == groupId }
companion object {
private const val TOTAL = 250
private const val LIMIT = 100
private const val SAFETY_CAP = 10
}
}