Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.
Seven decisions settled by the org lead before any code:
- email only moves; the push tickle and the Discord bridge stay direct calls
- the EVENT carries its access-checked audience, and `members` resolves to it
- the four Team rules are seeded DISABLED, with an admin banner and a note
- team_notification_prefs stays, read by the engine as a scoped preference
- the payload wins and a structural projection fills the gaps
- the digest keeps computing at send time; only its state generalizes
- an unsubscribe token turns off the channel it names, and nothing else
Three defects found while building it:
- `email.button` never absolutized its href, while image and itemList both
did. Every rule-driven CTA would have been a dead relative link, because a
trigger's url variables are validated site-relative by construction.
- Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
not to build. An outbox row snapshots the payload and so has none of the
three properties the digest design exists for, including the security one.
- the digest's send-log row carried no address_hash while the instant row
beside it did, which would have made half the mail uncorrelatable in Phase 9.
Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.
Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.
Docs: RunicGateway/docs#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
467 lines
23 KiB
JavaScript
467 lines
23 KiB
JavaScript
// The Team notification fan-out and the digest worker (TEAMS.md §6.2/§6.4,
|
||
// migrated onto the engagement engine in ENGAGEMENT.md Phase 6).
|
||
//
|
||
// The layer above teamNotify.test.js: that one asserts WHO a recipient set
|
||
// contains, this one asserts what actually happens to them — which stream fires,
|
||
// what leaves for the engine, and the ways a notification is correctly suppressed.
|
||
//
|
||
// **What Phase 6 changed in this file, and what it deliberately did not.** The
|
||
// immediate email is no longer sent from here: `forumPost` emits an event and the
|
||
// engine decides. So the assertions about mail bodies moved down to the email
|
||
// channel's own tests, and what is asserted here is the ENVELOPE — the recipient
|
||
// set, the scope, the payload — because that is now the whole of this file's
|
||
// contract with the rest of the system. Every suppression test survives unchanged
|
||
// in intent, and each one is a refusal that would be invisible until it went
|
||
// wrong in production:
|
||
//
|
||
// • forums switched off silences forum notifications, including the digest;
|
||
// • no enabled rule means Team email is off (Phase 6, decision 3);
|
||
// • a Team's FIRST roster does not wake 155 phones;
|
||
// • a failed send does not stamp the digest window, so it is retried rather
|
||
// than silently skipped.
|
||
const { test, beforeEach, afterEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const notify = require('../src/utils/teamNotify')
|
||
const digest = require('../src/utils/teamDigestWorker')
|
||
const pushDispatch = require('../src/utils/pushDispatch')
|
||
const mailer = require('../src/utils/mailer')
|
||
const engagementEmit = require('../src/utils/engagementEmit')
|
||
const forumSettings = require('../src/model/teams/teamForumSettings.model')
|
||
const notifyModel = require('../src/model/teams/teamNotify.model')
|
||
const digestDb = require('../src/model/engagement/engagementDigest.db')
|
||
const rulesDb = require('../src/model/engagement/engagementRules.db')
|
||
const sendsDb = require('../src/model/engagement/engagementSends.db')
|
||
const templates = require('../src/engagement/templates')
|
||
const registries = require('../src/modules/registries')
|
||
|
||
const saved = new Map()
|
||
|
||
function patch(mod, name, fn) {
|
||
if (!saved.has(mod)) saved.set(mod, new Map())
|
||
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
|
||
mod[name] = fn
|
||
}
|
||
|
||
function restore() {
|
||
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
|
||
saved.clear()
|
||
}
|
||
|
||
const TEAM = { id: 1, slug: 'silver-hand', name: 'The Silver Hand', external_id: 'g1', display_name_override: null }
|
||
|
||
// The rule the digest worker's gate looks for. Enabled and naming the email
|
||
// channel, which is exactly what core does NOT seed — see the gate's own test.
|
||
const EMAIL_RULE = {
|
||
id: 4,
|
||
trigger_id: 'team.forum.post',
|
||
enabled: 1,
|
||
channels: ['email'],
|
||
template_keys: { email: 'notify.team-post', digest: 'notify.digest' },
|
||
}
|
||
|
||
let sent // tickles
|
||
let emitted // envelopes handed to the engine
|
||
let mails // rendered messages handed to the transport
|
||
let world
|
||
|
||
function stub({
|
||
forumsEnabled = true,
|
||
emailConfigured = true,
|
||
recipients = [10, 11],
|
||
emailRows = [],
|
||
sendOk = true,
|
||
} = {}) {
|
||
sent = []
|
||
emitted = []
|
||
mails = []
|
||
world = { stamped: [], logged: [] }
|
||
|
||
patch(forumSettings, 'forumsEnabled', async () => forumsEnabled)
|
||
patch(mailer, 'isConfigured', async () => emailConfigured)
|
||
patch(mailer, 'sendNotification', async (msg) => {
|
||
mails.push(msg)
|
||
return sendOk ? { ok: true, transport: 'smtp' } : { ok: false, retry: true, detail: 'relay said no' }
|
||
})
|
||
patch(pushDispatch, 'publishToUsers', async (streamId, payload) => { sent.push({ streamId, ...payload }) })
|
||
patch(engagementEmit, 'emit', (owner, triggerId, envelope) => {
|
||
emitted.push({ owner, triggerId, ...envelope })
|
||
return { ok: true }
|
||
})
|
||
patch(notifyModel, 'recipientIds', async (teamId, { exclude = [] } = {}) =>
|
||
recipients.filter((id) => !exclude.includes(id)))
|
||
patch(notifyModel, 'emailRecipients', async (teamId, { exclude = [] } = {}) =>
|
||
emailRows.filter((r) => !exclude.includes(r.user_id)))
|
||
patch(digestDb, 'stampsFor', async () => new Map())
|
||
patch(digestDb, 'stamp', async (userId, channel, scopeKey, at) => {
|
||
world.stamped.push({ userId, channel, scopeKey, at })
|
||
})
|
||
patch(rulesDb, 'enabledForTrigger', async (triggerId) => (triggerId === EMAIL_RULE.trigger_id ? [EMAIL_RULE] : []))
|
||
patch(sendsDb, 'record', async (entry) => { world.logged.push(entry) })
|
||
patch(templates, 'renderByKey', async (key, values) => ({
|
||
subject: `[${key}] ${values.periodLabel || values.title || ''}`,
|
||
html: '<p>rendered</p>',
|
||
text: 'rendered',
|
||
missing: [],
|
||
values,
|
||
}))
|
||
// No module registered: the default in most tests, so the link-building ones
|
||
// have to opt in and the absence is exercised rather than assumed.
|
||
patch(registries, 'registeredTeamProvider', () => null)
|
||
}
|
||
|
||
beforeEach(() => stub())
|
||
afterEach(restore)
|
||
|
||
// ── Which stream fires ─────────────────────────────────────────────────────
|
||
|
||
test('a discussion reply fires team.forum.post; an announcement fires its own stream', async () => {
|
||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||
await notify.forumPost({ team: TEAM, threadId: 8, threadTitle: 'Notice', type: 'announcement', authorUserId: 10 })
|
||
assert.deepEqual(sent.map((s) => s.streamId), ['team.forum.post', 'team.announcement'])
|
||
// The trigger and the stream are the same id under §7.2's one namespace, so
|
||
// the event that leaves for the engine names the same thing the tickle did.
|
||
assert.deepEqual(emitted.map((e) => e.triggerId), ['team.forum.post', 'team.announcement'])
|
||
})
|
||
|
||
test('the tickle is content-free and refs the thread, never the body', async () => {
|
||
await notify.forumPost({
|
||
team: TEAM, threadId: 7, threadTitle: 'Secret plans', type: 'discussion', authorUserId: 99,
|
||
bodyHtml: '<p>the actual private text</p>',
|
||
})
|
||
assert.deepEqual(Object.keys(sent[0]).sort(), ['ref', 'streamId', 'userIds'])
|
||
assert.equal(sent[0].ref, 'team:1:thread:7')
|
||
assert.equal(JSON.stringify(sent[0]).includes('private text'), false)
|
||
})
|
||
|
||
test('the author is not among the tickled, nor among the emitted audience', async () => {
|
||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||
assert.deepEqual(sent[0].userIds, [11])
|
||
// The same exclusion, on the same set: a forum that emails you your own post is
|
||
// the first thing anyone turns off.
|
||
assert.deepEqual(emitted[0].recipientUserIds, [11])
|
||
})
|
||
|
||
test('roster events fire one tickle for the run, not one per member', async () => {
|
||
await notify.memberJoined(TEAM)
|
||
await notify.leadershipChanged(TEAM)
|
||
assert.deepEqual(sent.map((s) => s.streamId), ['team.member.joined', 'team.leadership.changed'])
|
||
assert.equal(sent.every((s) => s.ref === 'team:1'), true)
|
||
})
|
||
|
||
// The tickle is per run and the EVENT is per person, and that split is the
|
||
// declaration's doing: `memberName` is a required single value, so five joiners
|
||
// cannot honestly be one event. The rule's cooldown is what stops five mails.
|
||
test('a roster sweep emits one event per joiner while tickling once', async () => {
|
||
await notify.memberJoined(TEAM, { count: 2, names: ['Darrow', 'Marisol'] })
|
||
assert.equal(sent.length, 1)
|
||
assert.deepEqual(emitted.map((e) => e.data.memberName), ['Darrow', 'Marisol'])
|
||
})
|
||
|
||
test('a leadership run with only demotions tickles but names no new leader', async () => {
|
||
await notify.leadershipChanged(TEAM, { names: [] })
|
||
assert.equal(sent.length, 1)
|
||
assert.equal(emitted.length, 0)
|
||
})
|
||
|
||
// ── What the envelope carries ──────────────────────────────────────────────
|
||
|
||
test('the event carries the access-checked audience, the scope and the team name', async () => {
|
||
await notify.forumPost({
|
||
team: TEAM, threadId: 7, threadTitle: 'Raid', type: 'discussion', authorUserId: 10,
|
||
authorName: 'Ten', bodyHtml: '<p>tonight</p>',
|
||
})
|
||
const event = emitted[0]
|
||
assert.equal(event.owner, 'core')
|
||
assert.deepEqual(event.recipientUserIds, [11])
|
||
// The SCOPE is the id, not the name: an unsubscribe token is signed over it and
|
||
// sits in a mailbox for months, and a Team renamed in between must not orphan
|
||
// the link. The name rides in the payload, where it is displayed and not keyed.
|
||
assert.equal(event.scopeKey, 'team:1')
|
||
assert.equal(event.data.teamName, 'The Silver Hand')
|
||
assert.equal(event.data.threadTitle, 'Raid')
|
||
assert.equal(event.data.excerpt, 'tonight')
|
||
})
|
||
|
||
test('an announcement names its title, a post names its thread title', async () => {
|
||
await notify.forumPost({ team: TEAM, threadId: 8, threadTitle: 'Notice', type: 'announcement', authorUserId: 10 })
|
||
// The two declarations differ here and a template can only name one of them —
|
||
// which is why the seeded announcement rule points at the generic body. Pinned
|
||
// so that reconciling the two declarations is a deliberate act with a version
|
||
// bump, not a silent rename that empties somebody's subject line.
|
||
assert.equal(emitted[0].data.title, 'Notice')
|
||
assert.equal(emitted[0].data.threadTitle, undefined)
|
||
})
|
||
|
||
test('the post url on the envelope is site-RELATIVE, as the emit contract requires', async () => {
|
||
patch(registries, 'registeredTeamProvider', () => ({ pageUrlTemplate: '/uo/guilds/{externalId}' }))
|
||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||
// An absolute one would be refused by `engagementEmit.RELATIVE_URL`, which
|
||
// exists so a `url` variable cannot carry a recipient off-site. The renderer
|
||
// absolutizes it against the deployment's base at send time.
|
||
assert.equal(emitted[0].data.postUrl, '/uo/guilds/g1?thread=7')
|
||
})
|
||
|
||
test('one post is one event however often the path re-runs', async () => {
|
||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||
assert.equal(emitted[0].dedupeKey, emitted[1].dedupeKey)
|
||
assert.match(emitted[0].dedupeKey, /^team:1:thread:7:/)
|
||
})
|
||
|
||
// ── Suppression ────────────────────────────────────────────────────────────
|
||
|
||
test('forums switched off silences a forum notification entirely', async () => {
|
||
stub({ forumsEnabled: false })
|
||
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||
// `bridged` is phase 8's third sink (§7.2). Asserted as part of the shape
|
||
// rather than ignored: "forums are off" has to silence every sink, and a test
|
||
// that only checked two would not notice a third one still firing.
|
||
assert.deepEqual(res, { push: 0, emitted: false, bridged: false })
|
||
assert.equal(sent.length, 0)
|
||
assert.equal(emitted.length, 0)
|
||
})
|
||
|
||
test('a roster tickle survives forums being off — it is not forum content', async () => {
|
||
stub({ forumsEnabled: false })
|
||
await notify.memberJoined(TEAM)
|
||
assert.equal(sent.length, 1)
|
||
})
|
||
|
||
test('no recipients means no publish call and no event at all', async () => {
|
||
stub({ recipients: [] })
|
||
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||
assert.equal(sent.length, 0)
|
||
// An event with an empty audience would enqueue nothing anyway; not emitting it
|
||
// keeps the send log and the emit log free of rows about nobody.
|
||
assert.equal(emitted.length, 0)
|
||
})
|
||
|
||
test('a fan-out never throws, whatever the layer below does', async () => {
|
||
patch(notifyModel, 'recipientIds', async () => { throw new Error('database is on fire') })
|
||
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||
assert.deepEqual(res, { push: 0, emitted: false, bridged: false })
|
||
assert.equal(await notify.memberJoined(TEAM), 0)
|
||
})
|
||
|
||
test('a refused emit is swallowed — a contract bug must not fail a forum write', async () => {
|
||
patch(engagementEmit, 'emit', () => { throw new Error('ctx.events.emit: payload is invalid') })
|
||
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
|
||
assert.equal(res.emitted, false)
|
||
assert.equal(res.push, 2 - 1) // the tickle still went out
|
||
})
|
||
|
||
// ── Links, and the module that supplies them ───────────────────────────────
|
||
|
||
test('with no module-supplied template there is no Team link, and nothing breaks', async () => {
|
||
assert.equal(notify.teamPageUrl(TEAM), null)
|
||
assert.equal(notify.threadUrl(TEAM, 7), null)
|
||
assert.equal(notify.teamPagePath(TEAM), null)
|
||
})
|
||
|
||
test('a registered template becomes an absolute link, and a thread deep-links by search param', () => {
|
||
patch(registries, 'registeredTeamProvider', () => ({ pageUrlTemplate: '/uo/guilds/{externalId}' }))
|
||
assert.match(notify.teamPageUrl(TEAM), /\/uo\/guilds\/g1$/)
|
||
assert.match(notify.threadUrl(TEAM, 7), /\/uo\/guilds\/g1\?thread=7$/)
|
||
// The path form is what travels on an envelope; the absolute form is what the
|
||
// Discord bridge posts, because a Discord client has no notion of this origin.
|
||
assert.equal(notify.teamPagePath(TEAM), '/uo/guilds/g1')
|
||
})
|
||
|
||
test('a Team is labelled by its display-name override where staff set one', () => {
|
||
assert.equal(notify.teamLabel(TEAM), 'The Silver Hand')
|
||
assert.equal(notify.teamLabel({ ...TEAM, display_name_override: 'Renamed' }), 'Renamed')
|
||
})
|
||
|
||
// ── The digest worker ──────────────────────────────────────────────────────
|
||
|
||
const DIGEST_ROW = { user_id: 11, username: 'eleven', email: 'e@example.test', email_mode: 'digest' }
|
||
|
||
function stubDigest({ posts = [], teams = [TEAM], rows = [DIGEST_ROW], sendOk = true, stamps = new Map() } = {}) {
|
||
patch(notifyModel, 'teamsWithForumActivitySince', async () => teams)
|
||
patch(notifyModel, 'emailRecipients', async () => rows)
|
||
patch(notifyModel, 'digestPostsSince', async () => posts)
|
||
patch(digestDb, 'stampsFor', async () => stamps)
|
||
patch(mailer, 'sendNotification', async (msg) => {
|
||
mails.push(msg)
|
||
return sendOk ? { ok: true, transport: 'smtp' } : { ok: false, retry: true, detail: 'relay said no' }
|
||
})
|
||
}
|
||
|
||
test('a digest gathers the Team’s new posts into one mail', async () => {
|
||
stubDigest({
|
||
posts: [
|
||
{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>tonight</p>', author_username: 'ten' },
|
||
{ id: 2, thread_id: 7, title: 'Raid', body_html: '<p>bring rope</p>', author_username: 'eleven' },
|
||
],
|
||
})
|
||
const res = await digest.tick(new Date())
|
||
assert.equal(res.sent, 1)
|
||
assert.equal(mails.length, 1)
|
||
assert.equal(mails[0].rendered.values.items.length, 2)
|
||
assert.equal(mails[0].to, 'e@example.test')
|
||
})
|
||
|
||
// §4.6.1: the digest's body is an operator-editable template, not a literal in
|
||
// `mailer`. The rule's `template_keys.digest` chooses it, and it is NOT
|
||
// `template_keys.email` — that one is written for a single event and would render
|
||
// a day of posts as one missing variable.
|
||
test('the digest renders the rule’s digest template, not its instant one', async () => {
|
||
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
|
||
await digest.tick(new Date())
|
||
assert.match(mails[0].rendered.subject, /^\[notify\.digest\]/)
|
||
})
|
||
|
||
test('a recipient with nothing new gets no mail and no stamp', async () => {
|
||
stubDigest({ posts: [] })
|
||
const res = await digest.tick(new Date())
|
||
assert.equal(res.sent, 0)
|
||
assert.equal(mails.length, 0)
|
||
assert.equal(world.stamped.length, 0, 'stamping here would move the window past unsent posts')
|
||
})
|
||
|
||
test('a failed send leaves the digest window alone so it is retried', async () => {
|
||
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }], sendOk: false })
|
||
const res = await digest.tick(new Date())
|
||
assert.equal(res.sent, 0)
|
||
assert.equal(world.stamped.length, 0)
|
||
// Failed, and recorded as failed. G15's question is "did user X get it?", and
|
||
// "no, and here is why" is an answer the send log has to be able to give.
|
||
assert.equal(world.logged[0].status, 'failed')
|
||
})
|
||
|
||
test('a successful send stamps exactly that (user, channel, scope)', async () => {
|
||
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
|
||
const now = new Date()
|
||
await digest.tick(now)
|
||
// The stamp moved out of `team_notification_prefs.last_digest_at` and into
|
||
// `engagement_digest_state` (§4.2b), keyed by channel and scope so a second
|
||
// digest needs no second column on somebody's preferences row.
|
||
assert.deepEqual(world.stamped, [{ userId: 11, channel: 'email', scopeKey: 'team:1', at: now }])
|
||
assert.equal(world.logged[0].status, 'sent')
|
||
assert.equal(world.logged[0].outbox_id, null, 'a digest has no outbox row, and the null says so')
|
||
})
|
||
|
||
test('only digest-mode recipients are swept', async () => {
|
||
stubDigest({
|
||
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
|
||
rows: [
|
||
DIGEST_ROW,
|
||
{ user_id: 12, username: 'twelve', email: 'i@example.test', email_mode: 'immediate' },
|
||
{ user_id: 13, username: 'thirteen', email: 'o@example.test', email_mode: 'off' },
|
||
],
|
||
})
|
||
await digest.tick(new Date())
|
||
assert.deepEqual(mails.map((m) => m.to), ['e@example.test'])
|
||
})
|
||
|
||
// The acceptance criterion, verbatim: a pre-existing `email_mode='digest'` row
|
||
// produces exactly one daily digest with the same window clamping. The stamp is
|
||
// the one the schema backfilled out of `team_notification_prefs`.
|
||
test('a pre-existing digest subscriber gets one digest, over the window their old stamp defines', async () => {
|
||
const now = new Date('2026-08-18T12:00:00Z')
|
||
const yesterday = new Date('2026-08-17T12:00:00Z')
|
||
let asked = null
|
||
stubDigest({
|
||
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
|
||
stamps: new Map([[11, yesterday]]),
|
||
})
|
||
patch(notifyModel, 'digestPostsSince', async (teamId, since) => {
|
||
asked = since
|
||
return [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }]
|
||
})
|
||
const res = await digest.tick(now)
|
||
assert.equal(res.sent, 1)
|
||
assert.equal(mails.length, 1)
|
||
assert.equal(asked.getTime(), yesterday.getTime())
|
||
})
|
||
|
||
// ── The three compute-at-send-time properties (§4.2b) ──────────────────────
|
||
//
|
||
// Each gets its own named test, the way the Teams phase-5 work gave the
|
||
// leader-can't-see-reports rule its own. They are the reason a digest is NOT
|
||
// assembled out of outbox rows, and a refactor that "unified" the two paths
|
||
// would break all three at once and pass every other test in this file.
|
||
|
||
test('property 1 — a two-day outage sends ONE digest, not two days of replay', async () => {
|
||
const now = new Date('2026-08-18T12:00:00Z')
|
||
stubDigest({
|
||
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
|
||
stamps: new Map([[11, new Date('2026-08-16T12:00:00Z')]]),
|
||
})
|
||
const res = await digest.tick(now)
|
||
assert.equal(res.sent, 1)
|
||
assert.equal(mails.length, 1)
|
||
})
|
||
|
||
test('property 2 — a post hidden after it was written is not in the query, so not in the mail', async () => {
|
||
// The moderator hid it, so `digestPostsSince` no longer returns it. There is
|
||
// nothing for the worker to remember to remove, which is the property: an
|
||
// outbox row snapshotted at publish time would still be carrying the text.
|
||
stubDigest({ posts: [] })
|
||
const res = await digest.tick(new Date())
|
||
assert.equal(res.sent, 0)
|
||
assert.equal(mails.length, 0)
|
||
})
|
||
|
||
test('property 3 — a user who lost access between the post and the send is not mailed', async () => {
|
||
// `emailRecipients` asks the same two tables the access resolver asks, at SEND
|
||
// time. A revoked guest is simply not in the answer. This is the one that would
|
||
// have been a security bug: the posts still exist and are still in the window.
|
||
stubDigest({
|
||
posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }],
|
||
rows: [],
|
||
})
|
||
const res = await digest.tick(new Date())
|
||
assert.equal(res.sent, 0)
|
||
assert.equal(mails.length, 0)
|
||
})
|
||
|
||
// ── The gate (Phase 6, decision 3) ─────────────────────────────────────────
|
||
|
||
test('no enabled email rule means no digest — the operator has not turned Team email on', async () => {
|
||
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
|
||
patch(rulesDb, 'enabledForTrigger', async () => [])
|
||
assert.equal((await digest.tick(new Date())).skipped, 'no-enabled-rule')
|
||
assert.equal(mails.length, 0)
|
||
})
|
||
|
||
test('a rule enabled on a channel other than email does not turn the digest on', async () => {
|
||
stubDigest({ posts: [{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_username: 'ten' }] })
|
||
patch(rulesDb, 'enabledForTrigger', async () => [{ ...EMAIL_RULE, channels: ['inapp'] }])
|
||
assert.equal((await digest.tick(new Date())).skipped, 'no-enabled-rule')
|
||
})
|
||
|
||
test('the sweep is skipped whole when forums are off or email is unconfigured', async () => {
|
||
stub({ forumsEnabled: false })
|
||
assert.equal((await digest.tick(new Date())).skipped, 'forums-disabled')
|
||
stub({ emailConfigured: false })
|
||
assert.equal((await digest.tick(new Date())).skipped, 'email-unconfigured')
|
||
})
|
||
|
||
test('the sweep never throws, and says so in its summary', async () => {
|
||
patch(notifyModel, 'teamsWithForumActivitySince', async () => { throw new Error('nope') })
|
||
assert.equal((await digest.tick(new Date())).skipped, 'error')
|
||
})
|
||
|
||
// ── The digest window ──────────────────────────────────────────────────────
|
||
|
||
test('a first digest reaches back one interval, not to the lookback floor', () => {
|
||
const now = new Date('2026-08-18T12:00:00Z')
|
||
const since = digest.clampSince(null, now)
|
||
assert.equal(now - since <= 24 * 60 * 60 * 1000, true)
|
||
})
|
||
|
||
test('a long outage is clamped: one digest, not a week of replay', () => {
|
||
const now = new Date('2026-08-18T12:00:00Z')
|
||
const ancient = new Date('2026-01-01T00:00:00Z')
|
||
const since = digest.clampSince(ancient, now)
|
||
assert.equal(now - since, digest.MAX_LOOKBACK_MS)
|
||
})
|
||
|
||
test('an ordinary last-send is used as-is', () => {
|
||
const now = new Date('2026-08-18T12:00:00Z')
|
||
const yesterday = new Date('2026-08-17T12:00:00Z')
|
||
assert.equal(digest.clampSince(yesterday, now).getTime(), yesterday.getTime())
|
||
})
|