test: extend NIP-29 group-chat coverage to every assembler + relay integration

Round out the branch's test plan across the headless-runnable tiers.

Tier B (filter shapes) — now every assembler:
- reconnect stability: a since-only bump on the state/tail filters is not a
  resend (no full replay on reconnect), while a history until step is; via
  FiltersChanged.needsToResendRequest.
- directory: extract buildRelayGroupDirectoryFilter into RelayGroupFilterBuilders
  (kinds 39000-39003, no d/h scope, limit 500) and point the RelayGroupsOnRelay
  assembler at it, with a shape test.
- ChannelPublic relay-group branch (filterRelayGroupState): state + pinned-id
  backfill and crucially NO message window.
- group notifications (filterGroupNotificationsToPubkey): #p+#h scope, kind set,
  empty-guards.
- discovery #p roster augmentation (filterRelayGroupsByAuthors): the author,
  #p-roster and #d-backfill filter shapes.

Tier C (serves-the-shape, against the in-process geode relay): state #d,
batched preview tail, threads, a pinned message reachable by id below the tail
window, notification #p+#h scoping, and the relay directory.

Tier C3/E1 (can't-miss + resilience): drive the production RelayLoadingCursors
backward over the wire to the bottom, and pin that a short page is not
exhaustion (only an empty page + EOSE ends the walk).

The remaining hostile-relay faults (echo-newest, no-EOSE, auth/stall) are
already covered generically by RelayLoadingCursorsTest, WindowLoadTrackerIdleTest
and BackwardRelayPagerTest; Tier D (device) and E2 (real third-party relay) are
not headless-runnable and stay flagged for a human in the test plan.

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:54:38 +00:00
parent 399bef6bf7
commit db5170a82e
9 changed files with 769 additions and 29 deletions
@@ -4,11 +4,20 @@
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 1–6 + 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`.
**Implemented on this branch (all headless-runnable tiers):**
- **Tier B — filter shapes, every assembler.**
- Assemblers 1–6 + card-warmup joined-skip + reconnect stability (`needsToResendRequest`) + directory: `amethyst/src/test/.../relayGroup/datasource/RelayGroupFilterBuildersTest.kt` (tests the pure `RelayGroupFilterBuilders.kt` the assemblers now delegate to).
- #9 ChannelPublic relay-group branch (state+pins, no message window): `.../publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt`.
- #10 group notifications (`#p`+`#h`): `.../service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt`.
- #8 discovery `#p` roster augmentation: `.../relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthorsTest.kt`.
- **Tier C — serves-the-shape, against the in-process `geode` relay** (`quartz/src/jvmAndroidTest/.../nip29RelayGroups/RelayGroupFilterServingRelayTest.kt`): C1 state `#d`, C2 batched preview tail, C5 threads, C6 pinned-body-below-window by id, C7 notification `#p`+`#h`, C8 directory.
- **Tier C3 / E1 — can't-miss + resilience** (`quartz/src/jvmAndroidTest/.../paging/RelayGroupHistoryPagingRelayTest.kt`): backward `#h` walk covers every message once + stops on empty page; same-relay group isolation with overlapping `createdAt`; the **production `RelayLoadingCursors`** driven to the bottom over the wire; short-page-≠-exhaustion.
Still to do: the remaining Tier B rows (7–10, reconnect stability), the wider Tier C `amy` integration set, Tier D device runs, and Tier E2 real-relay conformance.
**Deliberately not duplicated (already covered generically at the unit level):** echo-newest→done and rewind/prune are in `RelayLoadingCursorsTest`; no-EOSE watchdog in `WindowLoadTrackerIdleTest`; auth-CLOSED/stall/cannot-connect in `BackwardRelayPagerTest`. E1's remaining hostile-relay faults (ignore-`since`, out-of-order, AUTH-CLOSE, silence) are those same state-machine paths — re-asserting them under NIP-29 naming adds no coverage since the cursor/pager never sees the `#h` filter, only `onEvent(createdAt)`/`onEose`.
**Not headless-runnable in this environment (flagged for a human):**
- **Tier D** (Android emulator/device, per screen) — needs a device; each row must be run and any unrun row flagged.
- **Tier E2** (conformance against a *real* third-party NIP-29 relay in a container) — non-deterministic + needs a container image; geode/strfry are generic and can't surface a real NIP-29 relay's bugs.
## What is / isn't verifiable headless
@@ -64,6 +64,22 @@ 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)
/**
* A relay's whole-directory kinds — metadata + admins + members + roles (39000-39003), **no pins**.
* Narrower than [RELAY_GROUP_STATE_KINDS] on purpose: the directory lists groups, it doesn't need each
* group's pin list.
*/
val RELAY_GROUP_DIRECTORY_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
)
/** How many directory entries to pull per relay when browsing its whole group list. */
const val RELAY_GROUP_DIRECTORY_LIMIT = 500
/** `d`-tag key of the relay-signed state events (39xxx are addressable by the group id). */
private const val D_TAG = "d"
@@ -157,6 +173,24 @@ fun buildRelayGroupHistoryFilters(
)
}
/**
* The whole group directory a single [relay] hosts: kinds 39000-39003, unscoped by `d`/`h` (every group
* the relay signs), capped at [RELAY_GROUP_DIRECTORY_LIMIT]. Backs the "browse a relay's channels" screen.
*/
fun buildRelayGroupDirectoryFilter(
relay: NormalizedRelayUrl,
sinceEpoch: Long?,
): RelayBasedFilter =
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = RELAY_GROUP_DIRECTORY_KINDS,
limit = RELAY_GROUP_DIRECTORY_LIMIT,
since = sinceEpoch,
),
)
/** The Threads-tab feed for a single open group: kind-11/1111 `#h`-scoped on the host relay. */
fun buildRelayGroupThreadsFilter(
groupId: GroupId,
@@ -26,12 +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.nip01Core.relay.normalizer.NormalizedRelayUrl
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.SupportedRolesEvent
/** One screen's request for the full channel directory of a single relay. */
class RelayGroupsOnRelayQueryState(
@@ -39,14 +34,6 @@ class RelayGroupsOnRelayQueryState(
val account: Account,
)
private val RELAY_GROUP_DIRECTORY_KINDS =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
)
/**
* Subscribes to the relay-signed directory (kinds 39000-39003) of a single relay,
* so the "browse a relay's channels" screen sees every group the relay hosts. The
@@ -76,18 +63,7 @@ class RelayGroupsOnRelaySubAssembler(
override fun updateFilter(
key: RelayGroupsOnRelayQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
listOf(
RelayBasedFilter(
relay = key.relay,
filter =
Filter(
kinds = RELAY_GROUP_DIRECTORY_KINDS,
limit = 500,
since = since?.get(key.relay)?.time,
),
),
)
): List<RelayBasedFilter> = listOf(buildRelayGroupDirectoryFilter(key.relay, since?.get(key.relay)?.time))
override fun id(key: RelayGroupsOnRelayQueryState) = key.relay
}
@@ -0,0 +1,68 @@
/*
* 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.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The always-on group-notifications filter: content in NIP-29 groups that `p`-tags me, so a reply/mention
* reaches my notifications even for a group I never opened. Pins the `#p`+`#h` scope, the notification kind
* set, and the empty-guards (no pubkey / no groups ⇒ no REQ at all).
*/
class FilterGroupNotificationsToPubkeyTest {
private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay-a.example/")!!
private val me = "a".repeat(64)
@Test
fun `builds a single host p-and-h scoped filter over the notification kinds`() {
val filters = filterGroupNotificationsToPubkey(relay, me, listOf("g1", "g2"), since = 500L)
val f = filters.single()
assertEquals(relay, f.relay)
assertEquals(GroupNotificationKinds, f.filter.kinds)
assertEquals(listOf(me), f.filter.tags!!["p"])
assertEquals(listOf("g1", "g2"), f.filter.tags!!["h"])
assertEquals(200, f.filter.limit)
assertEquals(500L, f.filter.since)
assertNull(f.filter.until)
assertNull(f.filter.authors)
}
@Test
fun `notification kinds include chat, comments, reactions, reposts and zaps`() {
// A mention can arrive as any of these; the set must not silently drop one.
assertTrue(GroupNotificationKinds.contains(9)) // ChatEvent
assertTrue(GroupNotificationKinds.contains(1111)) // CommentEvent
assertTrue(GroupNotificationKinds.contains(7)) // ReactionEvent
assertTrue(GroupNotificationKinds.contains(9735)) // LnZapEvent
}
@Test
fun `no pubkey or no groups produces no filter`() {
assertTrue(filterGroupNotificationsToPubkey(relay, null, listOf("g1"), since = null).isEmpty())
assertTrue(filterGroupNotificationsToPubkey(relay, "", listOf("g1"), since = null).isEmpty())
assertTrue(filterGroupNotificationsToPubkey(relay, me, emptyList(), since = null).isEmpty())
}
}
@@ -0,0 +1,102 @@
/*
* 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.datasource.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
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 org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The relay-group branch of the open-channel filter (`ChannelPublic`). After the state/content split this
* must load **only** the group's relay-signed state (39000-39005, `#d`-scoped) plus an id-only backfill of
* pinned message bodies — and crucially **no** kind-9/poll message window (that job moved to the chat tail +
* history pager). This pins that contract so a regression can't quietly re-add a message-window REQ here.
*/
class FilterRelayGroupStateTest {
private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://relay-a.example/")!!
private val groupId = GroupId("g1", relayA)
private val relaySignKey = "b".repeat(64)
private val sig = "0".repeat(128)
private val stateKinds =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
GroupPinnedEvent.KIND,
)
@Test
fun `with no pins it is a single host d-scoped state filter and no message window`() {
val channel = RelayGroupChannel(groupId)
val filters = filterRelayGroupState(channel, since = null)
val f = filters.single()
assertEquals(relayA, f.relay)
assertEquals(stateKinds, f.filter.kinds)
assertEquals(listOf("g1"), f.filter.tags!!["d"])
assertNull("state is #d-scoped, never #h — the message window is the tail/pager's job", f.filter.tags!!["h"])
assertNull("state carries no message-window kinds (9/poll), so no limit either", f.filter.limit)
assertNull(f.filter.until)
}
@Test
fun `pins add an id-only backfill filter alongside the state filter`() {
val channel = RelayGroupChannel(groupId)
val pinnedIds = listOf("a".repeat(64), "c".repeat(64))
channel.updatePinned(
GroupPinnedEvent(
id = "d".repeat(64),
pubKey = relaySignKey,
createdAt = 100L,
tags = arrayOf(arrayOf("d", "g1"), arrayOf("e", pinnedIds[0]), arrayOf("e", pinnedIds[1])),
content = "",
sig = sig,
),
)
val filters = filterRelayGroupState(channel, since = null)
assertEquals(2, filters.size)
val pinFilter = filters.first { it.filter.ids != null }
assertEquals(relayA, pinFilter.relay)
assertEquals(pinnedIds, pinFilter.filter.ids)
assertNull("pinned bodies are fetched by id, so no kinds", pinFilter.filter.kinds)
assertNull("pinned events are immutable, so no since either", pinFilter.filter.since)
// The state filter is still present and still carries no #h message window.
val stateFilter = filters.first { it.filter.ids == null }
assertEquals(stateKinds, stateFilter.filter.kinds)
assertTrue(stateFilter.filter.tags!!.containsKey("d"))
assertNull(stateFilter.filter.tags!!["h"])
}
}
@@ -20,9 +20,15 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.FiltersChanged
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.nip51Lists.simpleGroupList.GroupTag
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
@@ -139,6 +145,31 @@ class RelayGroupFilterBuildersTest {
assertEquals(7L, f.filter.since)
}
// --- Directory (browse a relay): kinds 39000-39003, no d/h scope, limit 500 ---
@Test
fun `directory is an unscoped host filter over the four directory kinds`() {
val f = buildRelayGroupDirectoryFilter(relayA, 42L)
assertEquals(relayA, f.relay)
assertEquals(RELAY_GROUP_DIRECTORY_KINDS, f.filter.kinds)
assertEquals(RELAY_GROUP_DIRECTORY_LIMIT, f.filter.limit)
assertEquals(42L, f.filter.since)
assertNull("the directory lists every group, so it carries no d/h scope", f.filter.tags)
assertNull(f.filter.until)
assertNull(f.filter.authors)
}
@Test
fun `directory kinds are 39000-39003 and exclude the pin list`() {
assertEquals(
listOf(GroupMetadataEvent.KIND, GroupAdminsEvent.KIND, GroupMembersEvent.KIND, SupportedRolesEvent.KIND),
RELAY_GROUP_DIRECTORY_KINDS,
)
assertFalse("the directory doesn't fetch each group's pins", RELAY_GROUP_DIRECTORY_KINDS.contains(GroupPinnedEvent.KIND))
// The always-on state set DOES carry pins — the two kind sets must not be conflated.
assertTrue(RELAY_GROUP_STATE_KINDS.contains(GroupPinnedEvent.KIND))
}
// --- Card warmup joined-skip: joined groups are covered always-on, so warmup must skip them ---
@Test
@@ -157,4 +188,47 @@ class RelayGroupFilterBuildersTest {
assertTrue(buildRelayGroupStateFilters(emptySet()) { null }.isEmpty())
assertTrue(buildRelayGroupJoinedChatTailFilters(emptySet(), 1L).isEmpty())
}
// --- Reconnect stability: a `since`-only bump must NOT trigger a fresh REQ (no full replay) ---
//
// A RelayPool reconnect re-issues the current filter. If a post-EOSE `since` bump counted as a
// "changed" filter, every reconnect would replay the whole window. These pin that the always-on
// state + tail filters are reconnect-safe, while the backward pager's `until` step correctly IS a
// new request (each page is genuinely new data). This is the whole reason the refactor uses a shared
// time floor instead of a per-group `limit` + per-relay EOSE `since`.
@Test
fun `state gaining a since after EOSE is not a resend`() {
val firstLoad = buildRelayGroupStateFilters(joined) { null }.map { it.filter }
val afterEose = buildRelayGroupStateFilters(joined) { 500L }.map { it.filter }
assertEquals(firstLoad.size, afterEose.size)
firstLoad.zip(afterEose).forEach { (old, new) ->
assertFalse(
"adding a since once EOSE lands is a since-only change → no replay on reconnect",
FiltersChanged.needsToResendRequest(old, new),
)
}
}
@Test
fun `joined chat tail advancing its time floor is not a resend`() {
val earlier = buildRelayGroupJoinedChatTailFilters(joined, 1_000L).map { it.filter }
val later = buildRelayGroupJoinedChatTailFilters(joined, 2_000L).map { it.filter } // recentBoundary() crept forward
earlier.zip(later).forEach { (old, new) ->
assertFalse(
"a forward recent-tail floor bump is since-only → reconnect re-REQs the tail, not a full page",
FiltersChanged.needsToResendRequest(old, new),
)
}
}
@Test
fun `history stepping to an older until IS a resend`() {
val page1 = buildRelayGroupHistoryFilters(g1OnA, listOf(relayA), { 300L }, 50).single().filter
val page2 = buildRelayGroupHistoryFilters(g1OnA, listOf(relayA), { 200L }, 50).single().filter
assertTrue(
"each backward page moves until, which is genuinely new data and MUST re-REQ",
FiltersChanged.needsToResendRequest(page1, page2),
)
}
}
@@ -0,0 +1,117 @@
/*
* 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.subassemblies
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.tags.dTag.DTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
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 org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The discovery-feed augmentation that surfaces groups your follows are in. A relay-signed 39000 means a
* follow appears three ways; this pins the exact REQ shapes: (1) `{authors, kinds:[39000]}`, (2)
* `{kinds:[39001,39002], #p:<follows>}`, and (3) a `{kinds:[39000], #d:<their group ids>}` backfill for
* rosters already cached. `cachedChannels` is passed explicitly here so the test stays off `LocalCache`.
*/
class FilterRelayGroupsByAuthorsTest {
private val relayA = RelayUrlNormalizer.normalizeOrNull("wss://relay-a.example/")!!
private val relaySignKey = "b".repeat(64)
private val sig = "0".repeat(128)
private val f1 = "11".repeat(32) // sorts before f2
private val f2 = "22".repeat(32)
@Test
fun `empty authors produces no filters`() {
assertTrue(filterRelayGroupsByAuthors(relayA, emptySet(), since = null, cachedChannels = emptyList()).isEmpty())
}
@Test
fun `builds the author-signed and p-tagged roster filters, authors sorted`() {
val filters = filterRelayGroupsByAuthors(relayA, setOf(f2, f1), since = 7L, cachedChannels = emptyList())
assertEquals(2, filters.size)
// (1) groups whose relay signing key is a follow — an `authors` filter, no tags.
val byAuthor = filters.single { it.filter.authors != null }
assertEquals(relayA, byAuthor.relay)
assertEquals(listOf(f1, f2), byAuthor.filter.authors) // sorted, deterministic
assertEquals(listOf(GroupMetadataEvent.KIND), byAuthor.filter.kinds)
assertEquals(200, byAuthor.filter.limit)
assertEquals(7L, byAuthor.filter.since)
assertNull(byAuthor.filter.tags)
// (2) a follow is an admin/member — a #p filter over the roster kinds.
val byRoster = filters.single { it.filter.tags?.containsKey(PTag.TAG_NAME) == true }
assertEquals(listOf(GroupAdminsEvent.KIND, GroupMembersEvent.KIND), byRoster.filter.kinds)
assertEquals(listOf(f1, f2), byRoster.filter.tags!![PTag.TAG_NAME])
assertEquals(200, byRoster.filter.limit)
assertNull(byRoster.filter.authors)
}
@Test
fun `a cached roster containing a follow adds a d-scoped metadata backfill`() {
val channel = RelayGroupChannel(GroupId("g1", relayA))
channel.updateMembers(
GroupMembersEvent(
id = "e".repeat(64),
pubKey = relaySignKey,
createdAt = 1L,
tags = arrayOf(arrayOf("d", "g1"), arrayOf("p", f1)),
content = "",
sig = sig,
),
)
val filters = filterRelayGroupsByAuthors(relayA, setOf(f1), since = null, cachedChannels = listOf(channel))
assertEquals(3, filters.size)
val backfill = filters.single { it.filter.tags?.containsKey(DTag.TAG_NAME) == true }
assertEquals(listOf(GroupMetadataEvent.KIND), backfill.filter.kinds)
assertEquals(listOf("g1"), backfill.filter.tags!![DTag.TAG_NAME])
assertEquals(200, backfill.filter.limit)
}
@Test
fun `a cached roster with no follow in it adds no backfill`() {
val channel = RelayGroupChannel(GroupId("g1", relayA))
channel.updateMembers(
GroupMembersEvent(
id = "e".repeat(64),
pubKey = relaySignKey,
createdAt = 1L,
tags = arrayOf(arrayOf("d", "g1"), arrayOf("p", f2)), // f2 is NOT in the author set below
content = "",
sig = sig,
),
)
val filters = filterRelayGroupsByAuthors(relayA, setOf(f1), since = null, cachedChannels = listOf(channel))
assertEquals(2, filters.size) // only (1) and (2); no #d backfill
assertTrue(filters.none { it.filter.tags?.containsKey(DTag.TAG_NAME) == true })
}
}
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
@@ -143,6 +144,79 @@ class RelayGroupHistoryPagingRelayTest : RelayClientTest() {
return out.values.toList()
}
// --- Tier E1: the PRODUCTION cursor state machine, driven over a real relay ---
//
// The walks above use a hand-rolled until loop; these drive the actual `RelayLoadingCursors` — the
// state machine `RelayGroupOpenChatHistorySubAssembler` runs in production — against the live in-process
// relay, so a regression in the real advance/onEvent/onEose logic surfaces here, not just in the unit
// tests that feed it synthetic callbacks (RelayLoadingCursorsTest / BackwardRelayPagerTest).
/**
* Steps the production [RelayLoadingCursors] backward over one group's `#h` chat until it reports done,
* exactly as the history assembler does: advance → REQ at the requested `until` → feed each event and
* the EOSE back in → advance again. Returns every id delivered.
*/
private suspend fun driveCursorsToBottom(
groupId: String,
now: Long,
): Set<String> {
val cursors = RelayLoadingCursors()
cursors.floor = now
val relay = defaultRelayUrl
val seen = mutableSetOf<String>()
cursors.advance(relay, start = now)
var guard = 0
while (!cursors.isDone(relay) && guard++ < SAFETY_CAP) {
val (events, eose) = client.collectUntilEose(defaultRelayUrl, hFilter(groupId, cursors.requestedUntilFor(relay)))
events.forEach {
seen.add(it.id)
cursors.onEvent(relay, it.createdAt)
}
if (eose) cursors.onEose(relay)
cursors.advance(relay, start = now)
}
assertTrue(cursors.isDone(relay), "the production cursors must reach the bottom (empty page + EOSE)")
return seen
}
@Test
fun productionCursorsWalkOneGroupToTheBottomOverTheWire() =
runBlocking {
defaultRelay.preload(groupChat(idBase = 1, count = TOTAL, groupId = "g1"))
defaultRelay.preload(groupChat(idBase = 1_000_000, count = 30, groupId = "g2")) // must not leak into g1's walk
val seen = driveCursorsToBottom("g1", now = 10_000L)
assertEquals(TOTAL, seen.size, "the production RelayLoadingCursors must fetch every g1 message exactly once")
}
@Test
fun cursorsTreatAShortPageAsMoreToComeAndOnlyAnEmptyPageAsTheBottom() =
runBlocking {
// A group with fewer messages than the page limit returns a short first page — the relay capping
// the response, NOT the bottom. Only a following empty page + EOSE ends the walk. This is the
// property that stops a short page from silently truncating a group's history.
defaultRelay.preload(groupChat(idBase = 1, count = 5, groupId = "g1"))
val cursors = RelayLoadingCursors()
val now = 10_000L
cursors.floor = now
val relay = defaultRelayUrl
cursors.advance(relay, start = now)
val (page1, eose1) = client.collectUntilEose(defaultRelayUrl, hFilter("g1", cursors.requestedUntilFor(relay)))
page1.forEach { cursors.onEvent(relay, it.createdAt) }
if (eose1) cursors.onEose(relay)
assertEquals(5, page1.size)
assertTrue(page1.size < LIMIT, "precondition: the first page is short")
assertFalse(cursors.isDone(relay), "a short page must NOT be treated as exhaustion")
cursors.advance(relay, start = now)
val (page2, eose2) = client.collectUntilEose(defaultRelayUrl, hFilter("g1", cursors.requestedUntilFor(relay)))
page2.forEach { cursors.onEvent(relay, it.createdAt) }
if (eose2) cursors.onEose(relay)
assertTrue(page2.isEmpty())
assertTrue(cursors.isDone(relay), "an empty page + EOSE is the bottom")
}
private fun Event.isTaggedGroup(groupId: String) = tags.any { it.size >= 2 && it[0] == GroupIdTag.TAG_NAME && it[1] == groupId }
companion object {
@@ -0,0 +1,286 @@
/*
* 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.nip29RelayGroups
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.nip22Comments.CommentEvent
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.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Tier C of the NIP-29 group-chat test plan: each screen's REQ shape run against the **real in-process
* relay** (geode), proving the relay actually serves what the [RelayGroupFilterBuilders] ask for. The
* builders themselves are Android-module, so these mirror their exact shapes with inline [Filter]s (the
* shape↔builder equivalence is pinned separately by the amethyst-side `RelayGroupFilterBuildersTest`).
*
* Covers: state (`#d`), batched preview tail (`#h`, time-floored), threads (11/1111), pinned-body backfill
* by id below the window, notification `#p`+`#h` scoping, and the relay directory (39000-39003).
*/
class RelayGroupFilterServingRelayTest : RelayClientTest() {
private val relayKey = "b".repeat(64)
private val me = "a".repeat(64)
private val sig = "0".repeat(128)
/**
* A relay-signed **addressable** state event (`#d`-scoped) for a group. These must be the real
* `AddressableEvent` subclasses (not a bare [SyntheticEvents.fakeEvent]) so the store populates the
* addressable-coordinate columns the `#d` query path reads.
*/
private fun state(
idSeed: Int,
kind: Int,
groupId: String = "g1",
): Event {
val id = SyntheticEvents.hexId(idSeed)
val tags = arrayOf(arrayOf("d", groupId))
return when (kind) {
GroupMetadataEvent.KIND -> GroupMetadataEvent(id, relayKey, 100L, tags, "", sig)
GroupAdminsEvent.KIND -> GroupAdminsEvent(id, relayKey, 100L, tags, "", sig)
GroupMembersEvent.KIND -> GroupMembersEvent(id, relayKey, 100L, tags, "", sig)
SupportedRolesEvent.KIND -> SupportedRolesEvent(id, relayKey, 100L, tags, "", sig)
GroupPinnedEvent.KIND -> GroupPinnedEvent(id, relayKey, 100L, tags, "", sig)
else -> error("unexpected state kind $kind")
}
}
/** A group content event (`#h`-scoped), optionally `#p`-tagging a pubkey (a mention). */
private fun content(
idSeed: Int,
kind: Int,
groupId: String,
at: Long,
pTag: String? = null,
): Event {
val tags =
if (pTag == null) {
arrayOf(arrayOf(GroupIdTag.TAG_NAME, groupId))
} else {
arrayOf(arrayOf(GroupIdTag.TAG_NAME, groupId), arrayOf("p", pTag))
}
return SyntheticEvents.fakeEvent(idSeed = idSeed, kind = kind, createdAt = at, tags = tags)
}
// --- C1: state load (#d, 39000-39005) ---
@Test
fun stateFilterReturnsEveryRelaySignedStateKind() =
runBlocking {
defaultRelay.preload(
listOf(
state(1, GroupMetadataEvent.KIND),
state(2, GroupAdminsEvent.KIND),
state(3, GroupMembersEvent.KIND),
state(4, SupportedRolesEvent.KIND),
state(5, GroupPinnedEvent.KIND),
),
)
val (events, eose) =
client.collectUntilEose(
defaultRelayUrl,
Filter(
kinds =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
GroupPinnedEvent.KIND,
),
tags = mapOf("d" to listOf("g1")),
),
)
assertTrue(eose)
assertEquals(
setOf(39000, 39001, 39002, 39003, 39005),
events.map { it.kind }.toSet(),
)
}
// --- C2: batched preview tail (#h, time-floored, both groups in one REQ) ---
@Test
fun batchedTailReturnsInWindowMessagesForEveryGroupOnTheRelay() =
runBlocking {
val floor = 1_000L
defaultRelay.preload(
listOf(
content(10, ChatEvent.KIND, "g1", at = 500L), // below floor → excluded
content(11, ChatEvent.KIND, "g1", at = 1_500L),
content(12, ChatEvent.KIND, "g1", at = 2_000L), // g1 newest
content(13, ChatEvent.KIND, "g2", at = 1_800L),
),
)
val (events, eose) =
client.collectUntilEose(
defaultRelayUrl,
Filter(
kinds = listOf(ChatEvent.KIND, PollEvent.KIND),
tags = mapOf(GroupIdTag.TAG_NAME to listOf("g1", "g2")),
since = floor,
),
)
assertTrue(eose)
assertTrue(events.all { it.createdAt >= floor }, "nothing below the floor may leak in")
// Batched: one #h REQ carries both groups' recent chat.
assertEquals(setOf("g1", "g2"), events.mapNotNull { it.groupTag() }.toSet())
assertEquals(3, events.size)
// The Messages-list preview must reflect the true newest for g1.
assertEquals(2_000L, events.filter { it.groupTag() == "g1" }.maxOf { it.createdAt })
}
// --- C5: threads tab (kind 11 + 1111) ---
@Test
fun threadsFilterReturnsThreadRootsAndComments() =
runBlocking {
defaultRelay.preload(
listOf(
content(20, ThreadEvent.KIND, "g1", at = 100L),
content(21, CommentEvent.KIND, "g1", at = 110L),
content(22, ChatEvent.KIND, "g1", at = 120L), // chat, must NOT match the threads filter
),
)
val (events, eose) =
client.collectUntilEose(
defaultRelayUrl,
Filter(
kinds = listOf(ThreadEvent.KIND, CommentEvent.KIND),
tags = mapOf(GroupIdTag.TAG_NAME to listOf("g1")),
),
)
assertTrue(eose)
assertEquals(setOf(ThreadEvent.KIND, CommentEvent.KIND), events.map { it.kind }.toSet())
assertTrue(events.none { it.kind == ChatEvent.KIND }, "the kind-9 chat must not surface in the threads feed")
}
// --- C6: a pinned message older than the window is reachable by id even when the tail excludes it ---
@Test
fun pinnedMessageBelowTheWindowIsUnreachableByTailButFetchableById() =
runBlocking {
val floor = 1_000L
val pinned = content(30, ChatEvent.KIND, "g1", at = 200L) // older than the tail floor
defaultRelay.preload(listOf(pinned, content(31, ChatEvent.KIND, "g1", at = 1_500L)))
val (tail, _) =
client.collectUntilEose(
defaultRelayUrl,
Filter(
kinds = listOf(ChatEvent.KIND, PollEvent.KIND),
tags = mapOf(GroupIdTag.TAG_NAME to listOf("g1")),
since = floor,
),
)
assertFalse(tail.any { it.id == pinned.id }, "the pinned old message is below the tail window")
val (byId, eose) = client.collectUntilEose(defaultRelayUrl, Filter(ids = listOf(pinned.id)))
assertTrue(eose)
assertEquals(listOf(pinned.id), byId.map { it.id })
}
// --- C7: notifications — only content that #p-tags me, scoped to the group ---
@Test
fun notificationFilterReturnsOnlyContentThatTagsMe() =
runBlocking {
defaultRelay.preload(
listOf(
content(40, ChatEvent.KIND, "g1", at = 100L, pTag = me), // mentions me
content(41, CommentEvent.KIND, "g1", at = 110L, pTag = me), // mentions me
content(42, ChatEvent.KIND, "g1", at = 120L), // no p-tag → not a notification
),
)
val (events, eose) =
client.collectUntilEose(
defaultRelayUrl,
Filter(
kinds = listOf(ChatEvent.KIND, CommentEvent.KIND),
tags = mapOf("p" to listOf(me), GroupIdTag.TAG_NAME to listOf("g1")),
),
)
assertTrue(eose)
assertEquals(setOf(40, 41).map { SyntheticEvents.hexId(it) }.toSet(), events.map { it.id }.toSet())
assertTrue(events.none { it.id == SyntheticEvents.hexId(42) }, "a message that doesn't tag me is not a notification")
}
// --- C8: relay directory (kinds 39000-39003, unscoped) ---
@Test
fun directoryFilterListsEveryGroupTheRelayHosts() =
runBlocking {
defaultRelay.preload(
listOf(
state(50, GroupMetadataEvent.KIND, groupId = "g1"),
state(51, GroupMetadataEvent.KIND, groupId = "g2"),
state(52, GroupAdminsEvent.KIND, groupId = "g1"),
),
)
val (events, eose) =
client.collectUntilEose(
defaultRelayUrl,
Filter(
kinds =
listOf(
GroupMetadataEvent.KIND,
GroupAdminsEvent.KIND,
GroupMembersEvent.KIND,
SupportedRolesEvent.KIND,
),
limit = 500,
),
)
assertTrue(eose)
val metadataGroups =
events.filter { it.kind == GroupMetadataEvent.KIND }.mapNotNull { it.dTagValue() }.toSet()
assertEquals(setOf("g1", "g2"), metadataGroups, "the directory lists both groups the relay hosts")
}
private fun Event.groupTag(): String? = tags.firstOrNull { it.size >= 2 && it[0] == GroupIdTag.TAG_NAME }?.get(1)
private fun Event.dTagValue(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
}