From ace12f7b0764b71f58c3be3608faadc52adf6e29 Mon Sep 17 00:00:00 2001 From: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Date: Sun, 21 Jun 2026 09:22:09 -0400 Subject: [PATCH] desktop: regression test for old reaction outside history window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reaction created before the content-kinds history window did not render on cold-load: the pre-#1153 single-filter history fetch (all CHANNEL_EVENT_KINDS under one limit) let reactions/deletions evict older reactions from the window, so the GUI showed no reaction and re-reacting hit the relay's "duplicate: reaction already exists" guard. #1153 fixed this by fetching history with content kinds only and backfilling reactions/edits/deletions by #e reference over the loaded message ids — recency-independent. This pins that contract end-to-end: a loaded message whose only reaction is outside the history window still renders the reaction (attributed to the current user) after the #e backfill. The aux filter must carry no since/until, or an old reaction becomes unreachable again; the test asserts that too. Fails if the aux backfill regains a recency cap or history starts competing reactions against message-depth budget again. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../messages/lib/auxBackfill.test.mjs | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/desktop/src/features/messages/lib/auxBackfill.test.mjs b/desktop/src/features/messages/lib/auxBackfill.test.mjs index 18cfc5538f..9851a1c002 100644 --- a/desktop/src/features/messages/lib/auxBackfill.test.mjs +++ b/desktop/src/features/messages/lib/auxBackfill.test.mjs @@ -6,6 +6,8 @@ import { collectMessageIdsForAuxBackfill, mergeAuxEventsWithDeletionBackfill, } from "./auxBackfill.ts"; +import { formatTimelineMessages } from "./formatTimelineMessages.ts"; +import { buildChannelAuxFilter } from "@/shared/api/relayChannelFilters.ts"; const CHANNEL_ID = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; @@ -126,3 +128,82 @@ test("merges deletion markers that target cached or fetched auxiliary event ids" [fetchedReactionId, cachedReactionDeletionId, fetchedReactionDeletionId], ); }); + +// Regression for the "duplicate: reaction already exists" report: a reaction +// older than the content-kinds history window never rendered, because the old +// single-filter history fetch (all CHANNEL_EVENT_KINDS under one `limit`) let +// reactions/deletions evict older reactions from the window. The fix (#1153) +// fetches history with content kinds only and backfills reactions by `#e` +// reference over the loaded message ids — recency-independent. This test pins +// that path end-to-end: a loaded message whose only reaction is NOT in the +// history window still renders that reaction after the `#e` backfill. +// +// Would fail pre-#1153: the reaction was never fetched, so the message +// rendered with no reactions and re-reacting hit the relay's duplicate guard. +test("old reaction outside the history window is backfilled by #e and renders", async () => { + const messageId = hex("1"); + const reactionId = hex("2"); + const currentUser = hex("c"); + + // The cold-load history window: the message, but NOT its (older) reaction. + const history = [ + event(messageId, 9, { + pubkey: hex("a"), + content: "ship it?", + created_at: 1_700_001_000, + }), + ]; + + // Step 1: backfill keys off the loaded content message ids. + const messageIds = collectMessageIdsForAuxBackfill(history); + assert.deepEqual(messageIds, [messageId]); + + // Step 2: the aux filter references those ids by `#e`, with no time window — + // so an old reaction is reachable regardless of when it was created. + const auxFilter = buildChannelAuxFilter(CHANNEL_ID, messageIds); + assert.deepEqual(auxFilter["#e"], [messageId]); + assert.equal("since" in auxFilter, false); + assert.equal("until" in auxFilter, false); + + // Step 3: the relay returns the old reaction for that `#e` filter. Its + // created_at predates the loaded message — the exact case the old window + // dropped. + const oldReaction = event(reactionId, 7, { + pubkey: currentUser, + content: "✅", + created_at: 1_700_000_000, + tags: [["e", messageId]], + }); + + const merged = await mergeAuxEventsWithDeletionBackfill({ + channelId: CHANNEL_ID, + cachedEvents: history, + fetchedAuxEvents: [oldReaction], + // No deletions target the reaction. + fetchAuxEventsForMessages: async () => [], + }); + assert.deepEqual( + merged.map((e) => e.id), + [reactionId], + ); + + // Step 4: the reaction merged into the timeline renders on its target + // message, attributed to the current user (so the UI shows it as already + // reacted and won't prompt a duplicate add). + const timeline = formatTimelineMessages( + [...history, ...merged], + null, + currentUser, + null, + ); + const row = timeline.find((m) => m.id === messageId); + assert.ok(row, "loaded message should render a timeline row"); + assert.deepEqual( + row.reactions?.map((r) => ({ + emoji: r.emoji, + count: r.count, + mine: r.reactedByCurrentUser, + })), + [{ emoji: "✅", count: 1, mine: true }], + ); +});