Merge pull request 'Fix digest email showing "0 replies / 0 reactions" for every event' (#23) from mailship-d08-digest-email-shows-0-replies-0-reactions-10f into main
All checks were successful
CI / checks (push) Successful in 35s
All checks were successful
CI / checks (push) Successful in 35s
Reviewed-on: #23 Reviewed-by: matt <matt@lorentz.is>
This commit is contained in:
commit
e22a5f457e
2 changed files with 175 additions and 1 deletions
|
|
@ -25,6 +25,7 @@ import { EVENT_VIEWER_URL } from './env.js'
|
||||||
import {
|
import {
|
||||||
profilesByPubkey,
|
profilesByPubkey,
|
||||||
loadProfile,
|
loadProfile,
|
||||||
|
repository,
|
||||||
} from './repository.js'
|
} from './repository.js'
|
||||||
|
|
||||||
type DigestData = {
|
type DigestData = {
|
||||||
|
|
@ -86,7 +87,6 @@ export class Digest {
|
||||||
|
|
||||||
sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => {
|
sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => {
|
||||||
const events = storedEvents.map(se => se.event)
|
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]))
|
const relayByEventId = new Map(storedEvents.map(se => [se.event.id, se.relay]))
|
||||||
|
|
||||||
// Load profiles for event authors
|
// 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
|
const data = { events, context, relayByEventId } as DigestData
|
||||||
|
|
||||||
if (data.events.length > 0) {
|
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