Fix digest email showing "0 replies / 0 reactions" for every event #23
2 changed files with 175 additions and 1 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
167
test/digest-reply-stats.test.ts
Normal file
167
test/digest-reply-stats.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue