Files
website/server/test/teamNotifyDispatch.test.js
wtclaude 673701c91b
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m14s
PR Checks / server-tests (pull_request) Successful in 6m4s
PR Checks / bot-tests (pull_request) Successful in 56s
fix(safety): team notifications skip members who block the author
#233 left this open: the forum hid a blocked author's posts from a
member, but the member was still pushed, put in the inbox and emailed
about them.

- teamNotify.forumPost excludes every member who blocks the post's
  author, or the member who started the thread (the forum 404s that
  thread for them). One recipient list, so push, the inbox and email
  skip the same people. userSafety.blockersOf answers it, and a staff
  author is blocked by nobody (S8). A failed lookup notifies without it.
- The daily digest applies each recipient's forumFilter at send time:
  blocked authors, blocked thread starters, hidden threads and posts.
  digestPostsSince now also returns the thread's created_by.
- A reply's notify payload carries threadAuthorUserId.

The Team activity feed needed nothing: forum posts are never written to
it (membership, renames and module game events only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-31 00:23:18 -05:00

527 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 safety = require('../src/model/safety/userSafety.model')
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,
blockers = {}, // authorUserId → the members who block them
readerFilter = {}, // userId → { blocked: [...], hiddenPosts: [...], hiddenThreads: [...] }
} = {}) {
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)
patch(safety, 'blockersOf', async (authorId) => blockers[authorId] || [])
patch(safety, 'forumFilter', async (userId) => {
const f = readerFilter[userId] || {}
return {
blockedUserIds: new Set(f.blocked || []),
hidden: { team_forum_thread: new Set(f.hiddenThreads || []), team_forum_post: new Set(f.hiddenPosts || []) },
}
})
}
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])
})
// USER_SAFETY.md §4.1: the forum leaves a blocked author's posts out of a reader's
// view, so the notification must not point that reader at one.
test('a member who blocks the author is not tickled or told', async () => {
stub({ recipients: [10, 11, 12], blockers: { 10: [12] } })
await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.deepEqual(sent[0].userIds, [11])
assert.deepEqual(emitted[0].recipientUserIds, [11])
})
test('a member who blocks the thread’s starter is not told about replies in it', async () => {
// A reply by 11 in a thread 10 started; 12 blocks 10, so cannot open the thread.
stub({ recipients: [10, 11, 12], blockers: { 10: [12] } })
await notify.forumPost({
team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 11, threadAuthorUserId: 10,
})
assert.deepEqual(sent[0].userIds, [10])
assert.deepEqual(emitted[0].recipientUserIds, [10])
})
test('a block lookup that fails still notifies everyone else', async () => {
patch(safety, 'blockersOf', async () => { throw new Error('database is on fire') })
const res = await notify.forumPost({ team: TEAM, threadId: 7, threadTitle: 'Hi', type: 'discussion', authorUserId: 10 })
assert.equal(res.push, 1)
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 digest leaves out what the forum leaves out for that reader', async () => {
stub({ readerFilter: { 11: { blocked: [40, 50], hiddenPosts: [3], hiddenThreads: [9] } } })
stubDigest({
posts: [
{ id: 1, thread_id: 7, title: 'Raid', body_html: '<p>kept</p>', author_user_id: 20, author_username: 'twenty' },
{ id: 2, thread_id: 7, title: 'Raid', body_html: '<p>blocked author</p>', author_user_id: 40, author_username: 'forty' },
{ id: 3, thread_id: 7, title: 'Raid', body_html: '<p>hidden post</p>', author_user_id: 20, author_username: 'twenty' },
{ id: 4, thread_id: 9, title: 'Hidden', body_html: '<p>hidden thread</p>', author_user_id: 20, author_username: 'twenty' },
{ id: 5, thread_id: 8, title: 'Theirs', body_html: '<p>blocked starter</p>', author_user_id: 20, author_username: 'twenty', thread_created_by: 50 },
],
})
await digest.tick(new Date())
assert.deepEqual(mails[0].rendered.values.items.map((i) => i.excerpt), ['kept'])
})
test('a digest whose every post is filtered out sends nothing and stamps nothing', async () => {
stub({ readerFilter: { 11: { blocked: [40] } } })
stubDigest({ posts: [{ id: 2, thread_id: 7, title: 'Raid', body_html: '<p>x</p>', author_user_id: 40, author_username: 'forty' }] })
const res = await digest.tick(new Date())
assert.equal(res.sent, 0)
assert.equal(world.stamped.length, 0)
})
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())
})