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

@@ -25,6 +25,11 @@ test('/auth/me push routes reject unauthenticated callers with 401', async () =>
['GET', '/api/v1/auth/me/notifications/streams'],
['GET', '/api/v1/auth/me/notifications/subscriptions'],
['PUT', '/api/v1/auth/me/notifications/subscriptions', { streams: ['news.post'] }],
// Phase 6's two. Per-Team preferences are as much a private fact as the
// subscriptions beside them — the LIST of Teams a user may be notified
// about answers "which guilds is this person in".
['GET', '/api/v1/auth/me/notifications/teams'],
['PUT', '/api/v1/auth/me/notifications/teams', { teams: [] }],
]
for (const [method, path, body] of calls) {
const res = await fetch(app.url + path, {
@@ -38,3 +43,40 @@ test('/auth/me push routes reject unauthenticated callers with 401', async () =>
await app.close()
}
})
// ── The unsubscribe endpoint (TEAMS.md §6.4) ───────────────────────────────
//
// The mirror image of every test above: this one is reached WITHOUT a session, on
// purpose, because the person following it is reading their mail. So the things
// worth asserting are that it is mounted unauthenticated, that it says the same
// thing whatever the token was, and that the GET twin does not write.
const publicRouter = require('../src/router/v1/public')
test('unsubscribe answers 200 unauthenticated, and says nothing about the token', async () => {
const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
try {
// A forged token and a well-formed one must be indistinguishable from the
// outside — otherwise this is an oracle for which (user, Team) pairs exist.
for (const token of ['1.1.1.AAAAAAAAAAAAAAAAAAAAAA', 'total-nonsense']) {
const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/${token}`, { method: 'POST' })
assert.equal(res.status, 200)
assert.deepEqual(await res.json(), { ok: true })
}
} finally {
await app.close()
}
})
test('a GET on the same path redirects and does not act', async () => {
const app = await startApp((a) => a.use('/api/v1/public', publicRouter))
try {
const res = await fetch(`${app.url}/api/v1/public/teams/unsubscribe/1.1.1.AAAAAAAAAAAAAAAAAAAAAA`, {
redirect: 'manual',
})
assert.equal(res.status, 302)
assert.match(res.headers.get('location') || '', /\/unsubscribe\//)
} finally {
await app.close()
}
})