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

@@ -126,3 +126,53 @@ test('publish skips endpoints that fail the SSRF guard', async () => {
assert.equal(calls[0].url, 'https://relay.test/ok')
})
})
// ── publishToUsers — the third fan-out shape (TEAMS.md §6.2) ───────────────
//
// `publish` answers "everyone subscribed" and "this one owner". Team
// notifications need "these N users", because the four `team.*` streams are
// global and which Team an event belongs to lives in the SET, not the stream id.
test('publishToUsers tickles the given set, and asks for exactly that set', async () => {
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
const { calls, fetchImpl } = captureFetch()
const asked = []
const pushDevices = {
endpointsForUsersStream: async (ids, stream) => {
asked.push({ ids, stream })
return [{ endpoint: 'https://relay.test/a' }, { endpoint: 'https://relay.test/b' }]
},
}
await pushDispatch.publishToUsers('team.forum.post', { ref: 'team:1:thread:7', userIds: [4, 9] }, { pushDevices, fetchImpl })
assert.deepEqual(asked, [{ ids: [4, 9], stream: 'team.forum.post' }])
assert.equal(calls.length, 2)
assert.deepEqual(JSON.parse(calls[0].opts.body), { stream: 'team.forum.post', ref: 'team:1:thread:7' })
})
})
test('publishToUsers with an empty set never touches the database', async () => {
const { calls, fetchImpl } = captureFetch()
let looked = false
const pushDevices = { endpointsForUsersStream: async () => { looked = true; return [] } }
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [] }, { pushDevices, fetchImpl })
assert.equal(looked, false, 'an empty IN () is a syntax error, so the query must not be made at all')
assert.equal(calls.length, 0)
})
test('publishToUsers de-duplicates and drops non-numeric ids', async () => {
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
const { fetchImpl } = captureFetch()
const asked = []
const pushDevices = {
endpointsForUsersStream: async (ids) => { asked.push(ids); return [] },
}
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [4, 4, null, 'nope', 9] }, { pushDevices, fetchImpl })
assert.deepEqual(asked, [[4, 9]])
})
})
test('publishToUsers never throws when the lookup fails', async () => {
const { fetchImpl } = captureFetch()
const pushDevices = { endpointsForUsersStream: async () => { throw new Error('down') } }
await pushDispatch.publishToUsers('team.forum.post', { ref: 'x', userIds: [1] }, { pushDevices, fetchImpl })
})