Files
website/server/test/displayAuthor.test.js
wtclaude 673701c91b
All checks were successful
PR Checks / client-build (pull_request) Successful in 1m14s
PR Checks / server-tests (pull_request) Successful in 6m4s
PR Checks / bot-tests (pull_request) Successful in 56s
fix(safety): team notifications skip members who block the author
#233 left this open: the forum hid a blocked author's posts from a
member, but the member was still pushed, put in the inbox and emailed
about them.

- teamNotify.forumPost excludes every member who blocks the post's
  author, or the member who started the thread (the forum 404s that
  thread for them). One recipient list, so push, the inbox and email
  skip the same people. userSafety.blockersOf answers it, and a staff
  author is blocked by nobody (S8). A failed lookup notifies without it.
- The daily digest applies each recipient's forumFilter at send time:
  blocked authors, blocked thread starters, hidden threads and posts.
  digestPostsSince now also returns the thread's created_by.
- A reply's notify payload carries threadAuthorUserId.

The Team activity feed needed nothing: forum posts are never written to
it (membership, renames and module game events only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-31 00:23:18 -05:00

165 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Who a deleted account's snapshots show as (ACCOUNT_DELETION.md AD5, AD6) ──
//
// The rule itself, then each place a member can read a snapshot: forum threads
// and posts, the leader's guest list, and the Team digest mail. The Reports
// queue (staff-only) is covered in contentReports.test.js.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const { test, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')
const { displayAuthor, DELETED_PLAYER, seesDeletedNames, hasActor } = require('../src/utils/displayAuthor')
const forum = require('../src/model/teams/teamForum.model')
const grants = require('../src/model/teams/teamGrants.model')
const accessDb = require('../src/model/teams/teamAccess.db')
const teamsDb = require('../src/model/teams/teams.db')
const db = require('../src/utils/db')
after(() => db.close())
const saved = []
function patch(obj, key, value) {
saved.push([obj, key, obj[key]])
obj[key] = value
}
afterEach(() => {
while (saved.length) {
const [obj, key, value] = saved.pop()
obj[key] = value
}
})
const MEMBER = { role: 'player' }
const STAFF = ['admin', 'editor', 'moderator'].map((role) => ({ role }))
// ── the rule ───────────────────────────────────────────────────────────────
test('a live account shows its name to everyone', () => {
for (const viewer of [null, MEMBER, ...STAFF]) {
assert.deepEqual(displayAuthor({ userId: 4, username: 'Thorne' }, viewer), { name: 'Thorne', deleted: false })
}
})
test('a deleted account reads "[deleted player]" to members and visitors (AD5)', () => {
assert.equal(DELETED_PLAYER, '[deleted player]')
for (const viewer of [null, undefined, MEMBER, { role: 'leader' }, {}]) {
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: DELETED_PLAYER, deleted: true })
}
})
test('staff — admin, editor and moderator — see the stored name, marked deleted (AD6)', () => {
for (const viewer of STAFF) {
assert.equal(seesDeletedNames(viewer), true)
assert.deepEqual(displayAuthor({ userId: null, username: 'Ravenna' }, viewer), { name: 'Ravenna', deleted: true })
}
// With no snapshot at all there is no name to show anybody.
assert.deepEqual(displayAuthor({ userId: null, username: null }, STAFF[0]), { name: DELETED_PLAYER, deleted: true })
})
test('an absent actor is not a deleted one', () => {
assert.equal(hasActor(null, null), false)
assert.equal(hasActor(null, ''), false)
assert.equal(hasActor(null, 'OldMod'), true)
assert.equal(hasActor(5, null), true)
})
// ── forum threads and posts ────────────────────────────────────────────────
const threadRow = (over = {}) => ({
id: 5, type: 'discussion', title: 'Raid night', created_by: null, created_username: 'Ravenna',
created_at: new Date(), last_post_at: new Date(), post_count: 2, pinned: 0, locked: 0, status: 'visible', ...over,
})
const postRow = (over = {}) => ({
id: 80, thread_id: 5, author_user_id: null, author_username: 'Ravenna', body_html: '<p>Guide</p>',
created_at: new Date(), edited_at: null, status: 'visible', ...over,
})
test('a forum thread by a deleted account: "[deleted player]" to a member, the name to staff', () => {
assert.equal(forum.publicThread(threadRow(), MEMBER).author, DELETED_PLAYER)
assert.equal(forum.publicThread(threadRow()).author, DELETED_PLAYER) // no viewer said
assert.equal(forum.publicThread(threadRow(), MEMBER).authorDeleted, true)
for (const viewer of STAFF) {
const t = forum.publicThread(threadRow(), viewer)
assert.equal(t.author, 'Ravenna')
assert.equal(t.authorDeleted, true)
}
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).author, 'Ravenna')
assert.equal(forum.publicThread(threadRow({ created_by: 4 }), MEMBER).authorDeleted, false)
})
test('a forum post by a deleted account follows the same rule, and is nobody’s to edit', () => {
const asMember = forum.renderPost(postRow(), 'disabled', { userId: 9, role: 'player', isStaff: false })
assert.equal(asMember.author, DELETED_PLAYER)
assert.equal(asMember.authorDeleted, true)
assert.equal(asMember.mine, false)
assert.equal(asMember.canEdit, false)
const asEditor = forum.renderPost(postRow(), 'disabled', { userId: 2, role: 'editor', isStaff: false })
assert.equal(asEditor.author, 'Ravenna')
assert.equal(asEditor.authorDeleted, true)
})
test('the thread list and a thread pass the viewer down to every row', async () => {
const forumDb = require('../src/model/teams/teamForum.db')
const forumSettings = require('../src/model/teams/teamForumSettings.model')
patch(forumDb, 'threadsByTeam', async () => [threadRow()])
patch(forumDb, 'threadById', async () => ({ ...threadRow(), team_id: 1 }))
patch(forumDb, 'postsByThread', async () => [postRow()])
patch(forumSettings, 'imageMode', async () => 'disabled')
const [listedForMember] = await forum.listThreads(1, { viewer: MEMBER })
assert.equal(listedForMember.author, DELETED_PLAYER)
const [listedForStaff] = await forum.listThreads(1, { viewer: { role: 'moderator' } })
assert.equal(listedForStaff.author, 'Ravenna')
const thread = await forum.getThread(1, 5, { viewer: { userId: 9, role: 'player' } })
assert.equal(thread.author, DELETED_PLAYER)
assert.equal(thread.posts[0].author, DELETED_PLAYER)
const staffThread = await forum.getThread(1, 5, { canModerate: true, viewer: { userId: 2, role: 'admin', isStaff: true } })
assert.equal(staffThread.author, 'Ravenna')
assert.equal(staffThread.posts[0].author, 'Ravenna')
})
// ── the leader's guest list ────────────────────────────────────────────────
test('a deleted account is not a forum guest, and a deleted granter reads "[deleted player]" (AD13)', async () => {
patch(accessDb, 'activeGrants', async () => [
{ user_id: 21, username: 'Guest', granted_by: null, granted_username: 'OldLeader', granted_at: new Date(), reason: null },
{ user_id: null, username: 'Ravenna', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
{ user_id: 22, username: 'Member', granted_by: 4, granted_username: 'Leader', granted_at: new Date(), reason: null },
])
patch(teamsDb, 'membersByTeam', async () => [{ user_id: 22 }])
const guests = await grants.forumGuests(1, MEMBER)
assert.deepEqual(guests.map((g) => g.username), ['Guest'])
assert.equal(guests[0].grantedBy, DELETED_PLAYER)
const forStaff = await grants.forumGuests(1, { role: 'moderator' })
assert.equal(forStaff[0].grantedBy, 'OldLeader')
})
// ── the Team digest ────────────────────────────────────────────────────────
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' },
])
patch(templates, 'renderByKey', async (key, values) => {
items = values.items
return null // stop before anything is sent
})
await worker.sendOne({ id: 1, slug: 'wolves', name: 'Iron Wolves' }, { user_id: 9, last_digest_at: null }, null, new Date())
assert.deepEqual(items.map((i) => i.heading), ['Raid night — [deleted player]', 'Raid night — Thorne'])
})