test(teams): the refusals, which is most of what a notification feature is

A notification feature is mostly things that correctly do NOT happen, and each of
these is invisible until it goes wrong in production: a departed member and a
revoked guest are not recipients; a mute subtracts per Team and leaves the user's
other Teams alone; the author of a post never receives the notification about it;
forums switched off silences the forum streams including the digest; a Team's
first roster wakes nobody; a failed send does not stamp `last_digest_at`.

Two real defects came out of writing them.

`Number(null)` is 0 and 0 is an integer, so a null in a caller's id list survived
`filter(Number.isInteger)` and rode into an IN clause as user id 0. No row has id
0, so it was harmless — which is exactly why it would never have been noticed.
Fixed in all three places that filter ids.

`recipientIds: db.recipientIds` in the model captured the function OBJECT at
require time, so the layer below could never be substituted. That is not only
untestable; it means the model was not really the seam it claimed to be. Wrapped
so `db.x` resolves at call time.

The registries catalog assertion is now an exact five-element list, so a
shard-content stream creeping back into core's registration fails here rather
than shipping.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 14:35:23 -05:00
parent b458c1f46f
commit 5fa88baa0a
7 changed files with 719 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ const registries = require('../src/modules/registries')
const teamsDb = require('../src/model/teams/teams.db')
const moderation = require('../src/model/teams/teamModeration.model')
const activity = require('../src/model/teams/teamActivity.model')
const teamNotify = require('../src/utils/teamNotify')
const settings = require('../src/model/settings/settings.model')
const teamSync = require('../src/model/teams/teamSync.model')
@@ -203,6 +204,13 @@ function stubDb() {
store.activity.push(item)
return true
})
// The push half of the same run (TEAMS.md §6.2, phase 6). Captured the same way
// and for the same reason: the fan-out is its own unit, and what belongs here is
// WHEN the reconciler decides to fire one.
store.tickles = []
patch(teamNotify, 'memberJoined', async () => { store.tickles.push('member.joined') })
patch(teamNotify, 'leadershipChanged', async () => { store.tickles.push('leadership.changed') })
}
// A provider whose answers the test controls. Defaults are authoritative and
@@ -851,6 +859,39 @@ test('the FIRST roster emits nothing — an import is not 155 people joining', a
await teamSync.reconcileNow('setup')
assert.equal(activeMembers(1).length, 2, 'the members did land')
assert.deepEqual(store.activity, [], 'and none of them was announced')
// The same suppression, and the half where it matters more: a notification per
// imported member would wake every phone in a 155-member guild at once.
assert.deepEqual(store.tickles, [], 'and nobody was notified either')
})
test('a roster run tickles ONCE per stream, however many members moved', async () => {
provide(withMembers([member('0x1')]))
await teamSync.reconcileNow('setup')
await resync(withMembers([
member('0x1'),
member('0x2', { displayName: 'Brenna' }),
member('0x3', { displayName: 'Cael' }),
]), 'test')
// Two joins, one tickle. The feed above records each of them, because it is a
// record; the tickle says "something happened here" and is content-free, so a
// second identical one carries no second piece of information.
assert.equal(store.activity.length, 2)
assert.deepEqual(store.tickles, ['member.joined'])
})
test('a leadership change tickles its own stream, separately from joins', async () => {
provide(withMembers([member('0x1'), member('0x2')]))
await teamSync.reconcileNow('setup')
await resync({
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
getTeamMembers: async () => ({ ok: true, complete: true, members: [member('0x1'), member('0x2')] }),
getTeamLeaders: async () => ({ ok: true, leaders: ['0x2'] }),
}, 'test')
assert.deepEqual(store.tickles, ['leadership.changed'])
})
test('a member arriving after the first roster is announced', async () => {