From db5170a82e71379254cfcc08158011885e3dbe3d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 17:54:38 +0000 Subject: [PATCH] test: extend NIP-29 group-chat coverage to every assembler + relay integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU --- .../2026-07-18-nip29-group-chat-test-plan.md | 17 +- .../datasource/RelayGroupFilterBuilders.kt | 34 +++ .../RelayGroupsOnRelayFilterAssembler.kt | 26 +- .../FilterGroupNotificationsToPubkeyTest.kt | 68 +++++ .../FilterRelayGroupStateTest.kt | 102 +++++++ .../RelayGroupFilterBuildersTest.kt | 74 +++++ .../FilterRelayGroupsByAuthorsTest.kt | 117 +++++++ .../RelayGroupHistoryPagingRelayTest.kt | 74 +++++ .../RelayGroupFilterServingRelayTest.kt | 286 ++++++++++++++++++ 9 files changed, 769 insertions(+), 29 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthorsTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt diff --git a/amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md b/amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md index ad1278c7c9..87d101c286 100644 --- a/amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md +++ b/amethyst/plans/2026-07-18-nip29-group-chat-test-plan.md @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt index bec6186c7a..f430c0b331 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuilders.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt index c09d8174eb..3fdbb97692 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupsOnRelayFilterAssembler.kt @@ -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 = - listOf( - RelayBasedFilter( - relay = key.relay, - filter = - Filter( - kinds = RELAY_GROUP_DIRECTORY_KINDS, - limit = 500, - since = since?.get(key.relay)?.time, - ), - ), - ) + ): List = listOf(buildRelayGroupDirectoryFilter(key.relay, since?.get(key.relay)?.time)) override fun id(key: RelayGroupsOnRelayQueryState) = key.relay } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt new file mode 100644 index 0000000000..44ecb93f77 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt @@ -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()) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt new file mode 100644 index 0000000000..3410d1c55a --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt @@ -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"]) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt index 380b53a157..c7d15d5bfd 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupFilterBuildersTest.kt @@ -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), + ) + } } diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthorsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthorsTest.kt new file mode 100644 index 0000000000..2b5a7e6d29 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthorsTest.kt @@ -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:}`, and (3) a `{kinds:[39000], #d:}` 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 }) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayGroupHistoryPagingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayGroupHistoryPagingRelayTest.kt index 83e1ad4e5c..0981ea070b 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayGroupHistoryPagingRelayTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/paging/RelayGroupHistoryPagingRelayTest.kt @@ -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 { + val cursors = RelayLoadingCursors() + cursors.floor = now + val relay = defaultRelayUrl + val seen = mutableSetOf() + 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 { diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt new file mode 100644 index 0000000000..1a65551596 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip29RelayGroups/RelayGroupFilterServingRelayTest.kt @@ -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) +}