test(teams): phase 5's server surface, and the negative property under it

1008 pass (972 before). The tests worth reading first are the ones that pin a
property no screen would look different without:

  * **The edit window is decided on the server, twice.** One test proves the read
    path stamps `canEdit` per post per viewer; another proves the WRITE path
    re-derives it from `created_at` and refuses a stale edit even though the
    client was told it could — because a time-bounded permission must not take its
    clock from the party it bounds.

  * **A locked thread refuses staff too**, asserted over member, leader and staff
    in one loop, at 409 rather than 403: well-formed request, refusing state.

  * **delete → restore is reversible for images.** Without the second half of the
    pair a restored post returns its words and loses its pictures a retention
    window later, silently — the test asserts both calls and that `hide` makes
    neither.

  * **Post moderation recomputes the thread's counters** rather than nudging them;
    the test runs hide → unhide → hide, which is the cycle a delta gets wrong.

  * **acceptance: nothing in the report model is reachable by a Team leader.** The
    negative property is the whole point of §5.6 and negatives are what nobody
    notices going, so it is asserted directly — the module's function surface is
    pinned, and `queue`/`handle` are checked not to mention leadership at all. If
    a leader-facing queue is ever wanted it is the org lead's decision, and this
    test is what makes somebody ask.

  * **A report never changes the content it is about**, proved by stubbing every
    mutation the forum has to throw. If filing a report touched a status then
    "report" would BE moderation, and the first person to work that out would have
    found a way to hide anything on the site.

The test suite caught one real defect: `describeTarget` returned `undefined` for a
hard-deleted target, and `undefined` is dropped by JSON.stringify — so the
documented `target: null` would have reached clients as an absent key.

Two phase-4 tests were updated rather than added to, both because phase 5 changed
what they describe: `canPost` split into `canPost` (open a discussion, everyone)
and `canAnnounce` (leaders), and `discussion` is no longer a refused thread type.
Phase 5's four new player routes are added to acceptance criterion 2's list, so
"with the forum off every forum route 404s" keeps covering the whole surface.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-18 12:58:15 -05:00
parent fff14848f1
commit 128de0ff2e
4 changed files with 660 additions and 13 deletions

View File

@@ -135,11 +135,21 @@ async function queue({ status, teamId, limit, offset } = {}) {
return rows.map((r) => ({ ...publicReport(r), target: describeTarget(r, { threadMap, postMap, uploadMap }) }))
}
/**
* The reported content, resolved.
*
* **Every miss returns `null`, never `undefined`.** They look interchangeable in
* JavaScript and are not in JSON: `undefined` is dropped by `JSON.stringify`, so
* a hard-deleted target would reach the client as an ABSENT `target` key rather
* than as an explicit null, and the queue's own contract says nullable. A client
* distinguishing "gone" from "not resolved yet" would get it wrong.
*/
function describeTarget(report, { threadMap, postMap, uploadMap }) {
const id = Number(report.target_id)
if (report.target_type === 'team_forum_thread') {
const t = threadMap.get(id)
return t && {
if (!t) return null
return {
kind: 'thread',
threadId: t.id,
title: t.title,
@@ -150,7 +160,8 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) {
}
if (report.target_type === 'team_forum_post') {
const p = postMap.get(id)
return p && {
if (!p) return null
return {
kind: 'post',
postId: p.id,
threadId: p.thread_id,
@@ -163,10 +174,11 @@ function describeTarget(report, { threadMap, postMap, uploadMap }) {
}
if (report.target_type === 'team_forum_upload') {
const u = uploadMap.get(id)
if (!u) return null
// §5.6's fourth rule: uploader, size and the SNIFFED type, without hunting.
// This is the payoff for §5.5.4's attribution table being load-bearing rather
// than bookkeeping.
return u && {
return {
kind: 'upload',
uploadId: u.id,
postId: u.post_id,