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:
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user