Merge branch 'main' into fix/lease-retry-206
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 52s
PR Checks / client-build (pull_request) Successful in 1m10s
PR Checks / server-tests (pull_request) Successful in 6m14s

This commit is contained in:
2026-11-02 11:15:17 +00:00
11 changed files with 167 additions and 6 deletions

View File

@@ -145,7 +145,12 @@ test('the digest mail names a deleted author "[deleted player]"', async () => {
const worker = require('../src/utils/teamDigestWorker')
const teamNotify = require('../src/model/teams/teamNotify.model')
const templates = require('../src/engagement/templates')
const safety = require('../src/model/safety/userSafety.model')
let items = null
patch(safety, 'forumFilter', async () => ({
blockedUserIds: new Set(),
hidden: { team_forum_thread: new Set(), team_forum_post: new Set() },
}))
patch(teamNotify, 'digestPostsSince', async () => [
{ id: 1, thread_id: 5, body_html: '<p>a</p>', created_at: new Date(), author_user_id: null, author_username: 'Ravenna', title: 'Raid night', type: 'discussion' },
{ id: 2, thread_id: 5, body_html: '<p>b</p>', created_at: new Date(), author_user_id: 4, author_username: 'Thorne', title: 'Raid night', type: 'discussion' },

View File

@@ -28,6 +28,7 @@ const notifyDb = require('../src/model/teams/teamNotify.db')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
const pushDispatch = require('../src/utils/pushDispatch')
const mailer = require('../src/utils/mailer')
const safety = require('../src/model/safety/userSafety.model')
const saved = new Map()
@@ -59,6 +60,7 @@ beforeEach(() => {
patch(notifyDb, 'emailRecipients', async () => [])
patch(forumSettings, 'forumsEnabled', async () => true)
patch(mailer, 'isConfigured', async () => false)
patch(safety, 'blockersOf', async () => [])
})
afterEach(restore)

View File

@@ -34,6 +34,7 @@ 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()
@@ -71,6 +72,8 @@ function stub({
recipients = [10, 11],
emailRows = [],
sendOk = true,
blockers = {}, // authorUserId → the members who block them
readerFilter = {}, // userId → { blocked: [...], hiddenPosts: [...], hiddenThreads: [...] }
} = {}) {
sent = []
emitted = []
@@ -108,6 +111,14 @@ function stub({
// 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())
@@ -142,6 +153,32 @@ test('the author is not among the tickled, nor among the emitted audience', asyn
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)
@@ -312,6 +349,29 @@ test('the digest renders the rule’s digest template, not its instant one', asy
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())

View File

@@ -201,6 +201,20 @@ test('a signed-out reader has no blocks and no hides', async () => {
assert.equal((await safety.forumFilter(null)).blockedUserIds.size, 0)
})
test('blockersOf answers who blocks an author, and nobody for a staff author (S8)', async () => {
patch(safetyDb, 'blockersOf', async () => [21, 22])
patch(safetyDb, 'userById', async (id) => ({ id, username: 'rusty', role: 'player' }))
assert.deepEqual(await safety.blockersOf(12), [21, 22])
patch(safetyDb, 'userById', async (id) => ({ id, username: 'mod', role: 'moderator' }))
assert.deepEqual(await safety.blockersOf(2), [])
// A deleted account or no author at all: nothing to look up.
patch(safetyDb, 'userById', async () => null)
assert.deepEqual(await safety.blockersOf(99), [])
assert.deepEqual(await safety.blockersOf(null), [])
})
test('staffAmong takes only positive integer ids', async () => {
let asked
patch(safetyDb, 'staffAmong', async (ids) => { asked = ids; return [] })