feat(teams): the phase 5 surface — discussion, replies, reports, and two admin screens
241 client tests pass (224 before).
**The forum panel becomes a forum.** It was "Announcements" with one composer;
it now has two, because phase 5 split one server capability into two: `canPost`
means "may open a discussion" and every participant may — a granted guest with no
game character included, which is path 3 doing its job — while `canAnnounce` is
the leader-only half `canPost` used to carry alone. Threads gain replies, an edit
control, per-post moderation and a report control, all still inside the one slot
the module declares, still navigating by `?thread=`.
**Almost nothing here is the client's decision, and the file says so.** `canPost`,
`canAnnounce`, `canReply` and each post's `canEdit`/`editableUntil` are read, not
computed. The one local judgement is a ticking clock that WITHDRAWS an edit offer
whose deadline passed while the page sat open — it can never grant one, because a
time-bounded permission must not take its clock from the party it bounds. That
asymmetry is the first thing client/test/teamForum.test.js asserts.
The panel's pure parts moved to `lib/teamForum.js` so they can be tested without a
browser, following teamActivity.js and teamAdmin.js. Two of them are subtler than
they look:
* `stripToText` decodes entities AFTER stripping tags, and `&` last of all.
Decoding first turns an author's literal "<script>" into a real tag the
strip pass then deletes — silently losing text that was never dangerous.
* `threadSummary` counts REPLIES, which is one fewer than `postCount`. Showing
the raw count tells a reader a brand-new thread already has one reply.
**Three admin surfaces.** The forum settings screen gains the edit-window field
(0 = posts permanent once written). The reports queue is a new screen beside
Appeals — under moderation rather than under Teams, because a staffer working a
queue should have one place to work and `target_type` is deliberately open-ended,
so the next reportable thing arrives as a row rather than as another nav entry.
Its copy tells a member where a report lands and that reporting changes nothing,
because a member who expects a post to vanish and watches it stay reports it
again. There is no leader-facing view and there is not meant to be.
And the per-Team forum moderation ledger finally renders: the route and
`api.admin.teamForumModeration()` have both existed since phase 4 with nothing
calling them, which made `actor_role` — the column that keeps a leader's ordinary
housekeeping distinguishable from a staff intervention — readable only from a DB
client.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -185,3 +185,70 @@ test('a module id is URL-encoded on the way into the path', async () => {
|
||||
await api.admin.disableModule('a b/c')
|
||||
assert.equal(calls[0].url, '/api/v1/admin/modules/a%20b%2Fc/disable')
|
||||
})
|
||||
|
||||
// ── Team forum, phase 5 ("5b") ──────────────────────────────────────────
|
||||
//
|
||||
// The URL shapes matter more here than they look. Replies hang off a THREAD;
|
||||
// edits and post moderation hang off a POST; and the report route hangs off the
|
||||
// forum rather than off either, because a report can name a thread, a post or an
|
||||
// upload and is not moderation of any of them.
|
||||
|
||||
test('a reply hangs off its thread and an edit hangs off its post', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReply('ossuary', 5, { body: 'hi' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/posts')
|
||||
assert.equal(calls[0].opts.method, 'POST')
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumEditPost('ossuary', 80, { body: 'fixed' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80')
|
||||
// PATCH, not POST: an edit replaces part of a post that already exists, and the
|
||||
// server's route is mounted on the verb.
|
||||
assert.equal(calls[0].opts.method, 'PATCH')
|
||||
})
|
||||
|
||||
test('post moderation is a different route from thread moderation', async () => {
|
||||
// Not the same route with a target kind, because the two answer to different
|
||||
// rules — `pin` and `lock` mean nothing to a post at all.
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumModeratePost('ossuary', 80, { action: 'hide' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/posts/80/moderate')
|
||||
|
||||
calls = []
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumModerate('ossuary', 5, { action: 'pin' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/threads/5/moderate')
|
||||
})
|
||||
|
||||
test('a report goes to the forum, and its queue is under admin moderation', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('ossuary', { targetType: 'team_forum_post', targetId: 80, reason: 'abuse' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/ossuary/forum/report')
|
||||
assert.deepEqual(JSON.parse(calls[0].opts.body), {
|
||||
targetType: 'team_forum_post', targetId: 80, reason: 'abuse',
|
||||
})
|
||||
|
||||
// Under /admin/moderation and NOT under /admin/teams: a staffer working a queue
|
||||
// should have one place to work, and there is deliberately no leader-facing
|
||||
// counterpart to this call anywhere in the client (TEAMS.md §5.6).
|
||||
calls = []
|
||||
willReply({ body: { reports: [] } })
|
||||
await api.admin.contentReports({ status: 'open' })
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports?status=open')
|
||||
})
|
||||
|
||||
test('the report queue defaults to the open work rather than to everything', async () => {
|
||||
willReply({ body: { reports: [] } })
|
||||
await api.admin.contentReports()
|
||||
// No query string at all — the server's default is open + reviewing, and a
|
||||
// client that pinned `status=all` here would put the archive in front of a
|
||||
// staffer every time they opened the screen.
|
||||
assert.equal(calls[0].url, '/api/v1/admin/moderation/reports')
|
||||
})
|
||||
|
||||
test('a Team slug is URL-encoded on every forum path', async () => {
|
||||
willReply({ body: { ok: true } })
|
||||
await api.teamForumReport('a b/c', { targetType: 'team_forum_thread', targetId: 1, reason: 'spam' })
|
||||
assert.equal(calls[0].url, '/api/v1/player/teams/a%20b%2Fc/forum/report')
|
||||
})
|
||||
|
||||
120
client/test/teamForum.test.js
Normal file
120
client/test/teamForum.test.js
Normal file
@@ -0,0 +1,120 @@
|
||||
// What the Team forum's client half decides for itself (client/src/lib/teamForum.js).
|
||||
//
|
||||
// The point of this file is how LITTLE that is. Who may post, who may moderate,
|
||||
// whether an image renders and whether a post may be edited are all server
|
||||
// answers the panel reads. What is tested here is the three places the client
|
||||
// turns those answers into what a reader sees — and one property that is easy to
|
||||
// break by accident: the edit offer can only ever be withdrawn here, never
|
||||
// granted.
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { REPORT_REASONS, editOfferOpen, stripToText, threadSummary } from '../src/lib/teamForum.js'
|
||||
|
||||
const NOW = new Date('2026-08-18T12:00:00Z').getTime()
|
||||
const inMinutes = (n) => new Date(NOW + n * 60_000).toISOString()
|
||||
|
||||
// ── the edit offer ─────────────────────────────────────────────────────────
|
||||
|
||||
test('the client can withdraw an edit offer and can never create one', () => {
|
||||
// The server said no. Nothing about a deadline changes that — a future
|
||||
// `editableUntil` on a post the server refused must not become an offer, or
|
||||
// the client would be granting a permission.
|
||||
assert.equal(editOfferOpen({ canEdit: false, editableUntil: inMinutes(10) }, NOW), false)
|
||||
assert.equal(editOfferOpen({ canEdit: false, editableUntil: null }, NOW), false)
|
||||
})
|
||||
|
||||
test('a deadline that has passed while the page sat open withdraws the offer', () => {
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW), true)
|
||||
// Same post, fifteen minutes of the reader staring at it later.
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: inMinutes(5) }, NOW + 15 * 60_000), false)
|
||||
})
|
||||
|
||||
test('no deadline means no deadline, not no permission', () => {
|
||||
// Staff are not time-bounded, and `editableUntil: null` is how the server says
|
||||
// so. Reading it as "expired" would take the edit control away from exactly the
|
||||
// people whose authority does not expire.
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: null }, NOW), true)
|
||||
})
|
||||
|
||||
test('an unparseable deadline closes the offer rather than opening it', () => {
|
||||
assert.equal(editOfferOpen({ canEdit: true, editableUntil: 'not a date' }, NOW), false)
|
||||
assert.equal(editOfferOpen(null, NOW), false)
|
||||
assert.equal(editOfferOpen(undefined, NOW), false)
|
||||
})
|
||||
|
||||
// ── round-tripping a body back into the composer ───────────────────────────
|
||||
|
||||
test('the image core generated is stripped, and the URL that made it survives', () => {
|
||||
// §5.5.3: the author wrote a URL, core emitted the <img> at read time. Handing
|
||||
// the <img> back would let an author edit markup they never wrote — and the
|
||||
// URL is what re-renders it, so nothing is lost by removing it.
|
||||
const rendered = '<p><a href="https://x/a.png" rel="noopener noreferrer">https://x/a.png</a>'
|
||||
+ '<img src="https://x/a.png" class="forum-embed" referrerpolicy="no-referrer" /></p>'
|
||||
const text = stripToText(rendered)
|
||||
assert.ok(!text.includes('<img'))
|
||||
assert.ok(text.includes('https://x/a.png'))
|
||||
})
|
||||
|
||||
test('paragraphs become blank lines and breaks become newlines', () => {
|
||||
assert.equal(stripToText('<p>One</p><p>Two</p>'), 'One\n\nTwo')
|
||||
assert.equal(stripToText('<p>One<br>Two</p>'), 'One\nTwo')
|
||||
// A paragraph carrying attributes is still a paragraph.
|
||||
assert.equal(stripToText('<p>One</p>\n<p class="x">Two</p>'), 'One\n\nTwo')
|
||||
})
|
||||
|
||||
test('entities decode to what the author typed, and only once', () => {
|
||||
assert.equal(stripToText('<p>Tom & Jerry</p>'), 'Tom & Jerry')
|
||||
assert.equal(stripToText('<p>"quoted"</p>'), '"quoted"')
|
||||
|
||||
// The one that bites: an author who typed a literal "<script>" has it stored
|
||||
// escaped. Decoding entities BEFORE stripping tags would turn it into a real
|
||||
// tag that the strip pass then deletes — silently losing text the author wrote
|
||||
// and which was never dangerous.
|
||||
assert.equal(stripToText('<p><script></p>'), '<script>')
|
||||
// And decoding & first would turn "&lt;" into "<" in two steps.
|
||||
assert.equal(stripToText('<p>&lt;</p>'), '<')
|
||||
})
|
||||
|
||||
test('an empty or absent body is an empty string, never a crash', () => {
|
||||
assert.equal(stripToText(''), '')
|
||||
assert.equal(stripToText(null), '')
|
||||
assert.equal(stripToText(undefined), '')
|
||||
assert.equal(stripToText('<p></p>'), '')
|
||||
})
|
||||
|
||||
// ── the thread list line ───────────────────────────────────────────────────
|
||||
|
||||
test('a discussion counts REPLIES, which is one fewer than its posts', () => {
|
||||
// postCount includes the opening post. Showing it raw would tell a reader a
|
||||
// brand-new thread already has one reply.
|
||||
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 1 }), 'ada')
|
||||
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 2 }), 'ada · 1 reply')
|
||||
assert.equal(threadSummary({ type: 'discussion', author: 'ada', postCount: 4 }), 'ada · 3 replies')
|
||||
})
|
||||
|
||||
test('an announcement says so and never counts replies, because it takes none', () => {
|
||||
const line = threadSummary({ type: 'announcement', author: 'aldric', postCount: 1 })
|
||||
assert.equal(line, 'Announcement · aldric')
|
||||
assert.ok(!line.includes('repl'))
|
||||
})
|
||||
|
||||
test('hidden is said out loud — it is only shown to whoever can unhide it', () => {
|
||||
assert.equal(
|
||||
threadSummary({ type: 'discussion', author: 'ada', postCount: 1, status: 'hidden' }),
|
||||
'ada · hidden',
|
||||
)
|
||||
})
|
||||
|
||||
// ── the report control ─────────────────────────────────────────────────────
|
||||
|
||||
test('every reason the server accepts is offered, and no others', () => {
|
||||
// The server validates against its own list; a client offering a reason the
|
||||
// server rejects produces a 400 the reporter cannot act on, and one MISSING a
|
||||
// reason quietly funnels those reports into "other".
|
||||
assert.deepEqual(
|
||||
REPORT_REASONS.map(([value]) => value).sort(),
|
||||
['abuse', 'illegal', 'impersonation', 'other', 'sexual', 'spam'],
|
||||
)
|
||||
assert.ok(REPORT_REASONS.every(([, label]) => typeof label === 'string' && label.length > 0))
|
||||
})
|
||||
Reference in New Issue
Block a user