fix: load reply/reaction events from repository into digest context
All checks were successful
CI / checks (pull_request) Successful in 34s

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
This commit is contained in:
Agent 2026-09-18 10:46:27 -04:00
parent 8235513822
commit ae836da033
2 changed files with 175 additions and 1 deletions

View file

@ -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) {

View file

@ -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('')}</${tagName}>`
return el
})
return {
displayDuration: vi.fn().mockReturnValue('1 hour'),
createElement: createElementMock,
}
})
// Captured digest parameters (set by the mailer mock)
let lastDigestParams: Record<string, any> | undefined
vi.mock('../src/mailer.js', () => ({
sendDigest: vi.fn((_sub: any, variables: Record<string, any>) => {
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>): 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)
})
})