mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
+34
@@ -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,
|
||||
|
||||
+1
-25
@@ -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
|
||||
}
|
||||
|
||||
+68
@@ -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())
|
||||
}
|
||||
}
|
||||
+102
@@ -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"])
|
||||
}
|
||||
}
|
||||
+74
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+117
@@ -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 })
|
||||
}
|
||||
}
|
||||
+74
@@ -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 {
|
||||
|
||||
+286
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user