From ae836da03367a72b4d72d0bdacf79cc9d570b82f Mon Sep 17 00:00:00 2001 From: Agent Date: Fri, 18 Sep 2026 10:46:27 -0400 Subject: [PATCH] fix: load reply/reaction events from repository into digest context Instead of setting context = events (which made repliesByParentId always empty), query the repository for events that #e-tag our matched event IDs with kinds NOTE, COMMENT, or REACTION. This gives buildParameters real reply/reaction data to count. Fixes the bug where every event in the digest email showed '0 replies / 0 reactions'. Added test/digest-reply-stats.test.ts that: - Publishes reply and reaction events into a mock repository - Calls sendFromStoredEvents with only the parent event - Asserts that the resulting digest parameters show Replies >= 1 and Reactions >= 1 --- src/digest.ts | 9 +- test/digest-reply-stats.test.ts | 167 ++++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 test/digest-reply-stats.test.ts diff --git a/src/digest.ts b/src/digest.ts index ef9850a..c9b2714 100644 --- a/src/digest.ts +++ b/src/digest.ts @@ -25,6 +25,7 @@ import { EVENT_VIEWER_URL } from './env.js' import { profilesByPubkey, loadProfile, + repository, } from './repository.js' type DigestData = { @@ -86,7 +87,6 @@ export class Digest { sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => { const events = storedEvents.map(se => se.event) - const context = [...events] // For now, context == events (no reply loading) const relayByEventId = new Map(storedEvents.map(se => [se.event.id, se.relay])) // Load profiles for event authors @@ -101,6 +101,13 @@ export class Digest { } } + // Load reply/reaction context: events that tag our matched events + const eventIds = events.map(e => e.id) + const replyEvents = repository.query([ + { '#e': eventIds, kinds: [NOTE, COMMENT, REACTION] }, + ]) + const context = [...events, ...replyEvents] + const data = { events, context, relayByEventId } as DigestData if (data.events.length > 0) { diff --git a/test/digest-reply-stats.test.ts b/test/digest-reply-stats.test.ts new file mode 100644 index 0000000..67032cf --- /dev/null +++ b/test/digest-reply-stats.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { TrustedEvent } from '@welshman/util' +import { spec } from '@welshman/lib' + +// ── Shared state accessible from both vi.mock factories and test body ────── +const mockRepo = vi.hoisted(() => { + const events: TrustedEvent[] = [] + return { + events, // shared mutable array + query: vi.fn((filters: any[]) => { + return events.filter(e => { + for (const f of filters) { + // #e filter: event must have an 'e' tag whose value matches one of the filter values + if (f['#e']) { + const eTagVals = e.tags.filter(t => t[0] === 'e').map(t => t[1]) + if (!f['#e'].some((id: string) => eTagVals.includes(id))) return false + } + // kinds filter + if (f.kinds && !f.kinds.includes(e.kind)) return false + } + return true + }) + }), + publish: vi.fn((event: TrustedEvent) => { + events.push(event) + return true + }), + } +}) + +// ── Mocks (hoisted before imports) ───────────────────────────────────────── +// ── Mock profiles map (pre-populated so waitForProfile doesn't time out) ── +const mockProfilesMap = vi.hoisted(() => new Map()) + +vi.mock('../src/repository.js', () => ({ + profilesByPubkey: { get: () => mockProfilesMap }, + loadProfile: vi.fn().mockResolvedValue(undefined), + repository: mockRepo, +})) + +vi.mock('../src/util.js', () => { + const createElementMock = vi.fn().mockImplementation((tagName: string) => { + const el: any = { tagName, children: [], innerText: '' } + el.appendChild = (child: any) => { el.children.push(child) } + el.toString = () => + `<${tagName}>${el.innerText}${el.children.map((c: any) => c.toString()).join('')}` + return el + }) + return { + displayDuration: vi.fn().mockReturnValue('1 hour'), + createElement: createElementMock, + } +}) + +// Captured digest parameters (set by the mailer mock) +let lastDigestParams: Record | undefined + +vi.mock('../src/mailer.js', () => ({ + sendDigest: vi.fn((_sub: any, variables: Record) => { + lastDigestParams = variables + }), +})) + +// ── Imports (after mocks) ────────────────────────────────────────────────── +import { Digest } from '../src/digest.js' +import type { Subscription } from '../src/alert.js' + +// Valid 64-char hex strings for nostr IDs and pubkeys +const PARENT_ID = 'aaa' + 'a'.repeat(61) // 64 hex chars +const REPLY_ID = 'bbb' + 'b'.repeat(61) +const REACTION_ID = 'ccc' + 'c'.repeat(61) +const PARENT_PK = 'ddd' + 'd'.repeat(61) +const REPLIER_PK = 'eee' + 'e'.repeat(61) +const REACTER_PK = 'fff' + 'f'.repeat(61) + +function makeEvent(overrides: Partial): TrustedEvent { + const eid = overrides.id || 'a'.repeat(64) + return { + id: eid, + kind: 1, + pubkey: PARENT_PK, + created_at: Math.floor(Date.now() / 1000), + tags: [], + content: 'test content', + ...overrides, + id: eid, + } as TrustedEvent +} + +describe('digest reply/reaction stats', () => { + let sub: Subscription + + beforeEach(() => { + // Reset shared state + mockRepo.events.length = 0 + lastDigestParams = undefined + + sub = { + id: 'sub-1', + key: 'key-1', + pubkey: PARENT_PK, + email: 'test@example.com', + frequency: 'daily', + created_at: Math.floor(Date.now() / 1000) - 3600, + confirmed_at: Math.floor(Date.now() / 1000) - 3600, + } + }) + + it('should count replies and reactions loaded from repository context', async () => { + // Create a parent event + const parentEvent = makeEvent({ + id: PARENT_ID, + kind: 1, + pubkey: PARENT_PK, + content: 'Hello world, this is the parent event', + created_at: Math.floor(Date.now() / 1000) - 600, + }) + + // Create a reply event referencing the parent via an 'e' tag + const replyEvent = makeEvent({ + id: REPLY_ID, + kind: 1, + pubkey: REPLIER_PK, + content: 'This is a reply to the parent', + created_at: Math.floor(Date.now() / 1000) - 500, + tags: [['e', PARENT_ID, '', 'root']], + }) + + // Create a reaction event (kind 7 = REACTION) + const reactionEvent = makeEvent({ + id: REACTION_ID, + kind: 7, + pubkey: REACTER_PK, + content: '+', + created_at: Math.floor(Date.now() / 1000) - 400, + tags: [['e', PARENT_ID, '', 'root']], + }) + + // Publish reply/reaction events to the mock repository so the fix can find them + mockRepo.publish(replyEvent) + mockRepo.publish(reactionEvent) + + // Pre-populate profiles so waitForProfile resolves immediately + mockProfilesMap.set(PARENT_PK, { pubkey: PARENT_PK, name: 'parent-user', picture: '' }) + mockProfilesMap.set(REPLIER_PK, { pubkey: REPLIER_PK, name: 'replier', picture: '' }) + mockProfilesMap.set(REACTER_PK, { pubkey: REACTER_PK, name: 'reacter', picture: '' }) + + const storedEvents = [ + { id: 'se-1', event: parentEvent, relay: 'wss://relay.example.com' }, + ] + + const digest = new Digest(sub) + await digest.sendFromStoredEvents(storedEvents) + + // sendDigest should have been called with the template parameters + expect(lastDigestParams).toBeDefined() + + const latest = lastDigestParams!.Latest + expect(latest.length).toBeGreaterThanOrEqual(1) + + const parentEntry = latest[0] + // When context includes reply/reaction events loaded from the repository, + // Replies should be >= 1 and Reactions >= 1 + expect(parentEntry.Replies).toBeGreaterThanOrEqual(1) + expect(parentEntry.Reactions).toBeGreaterThanOrEqual(1) + }) +}) \ No newline at end of file