Files
website/server/test/teamActivity.test.js
wtclaude aa332eda82 feat(teams): the activity feed, its two writers and its retention
TEAMS.md Part 4. `team_activity` takes items from two sources and treats them
identically on the read path: core writes its own membership and rename items
with source='core', and a module pushes game items through
`ctx.teams.activity.push`, which stops throwing and starts working.

Core writing here too is deliberate — the rendering path is exercised by core's
own content from day one, so the feed is never empty on a deployment whose
module pushes nothing.

Three rules shape the model:

  - core never composes a summary. It arrives already rendered and is stored
    verbatim; core cannot phrase "gained 15,000 gold" for a game whose
    vocabulary it does not know.
  - visibility fails closed. An item with no stated visibility is `members`.
  - a push never throws at its call site. It is called from inside a game-event
    handler, and a storage problem of core's must not become the module's
    control flow.

Core emits four of the five kinds §4.2 names — `core.forum.thread` has nothing
to emit it until the forum lands in phase 4 — and emits none of them for a
Team's FIRST roster: importing a 155-member guild is one Team arriving, not 155
people joining, and a join per member would bury every real event under the
import and reach the row cap on day one.

Retention ships with the feed rather than after someone notices. A nightly
worker applies an age horizon and a per-Team row cap, both settings; either
alone has a hole, since age lets one busy guild write a million rows inside the
window and a cap keeps a dead Team's feed forever.

The sync now reads member ROWS rather than keys, replacing the `memberKeys`
call rather than adding to it: the feed needs each changing member's display
name and prior `is_leader`, and the upsert is about to overwrite both.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-17 20:15:18 -05:00

272 lines
11 KiB
JavaScript

// The per-Team activity feed (docs/website/TEAMS.md Part 4).
//
// The db layer is stubbed and an in-memory table stands in for `team_activity`,
// so these are assertions about the RULES: what a module is allowed to write,
// what a caller is allowed to see, and what the prune takes away. The three worth
// protecting are the ones that are easy to "simplify" into a leak:
//
// 1. a module writes only into its OWN active Teams, named by external id;
// 2. visibility defaults to `members` and is resolved from the session, never
// from a request parameter;
// 3. a hidden Team's feed does not answer a public caller.
const { test, beforeEach, afterEach } = require('node:test')
const assert = require('node:assert/strict')
const activityDb = require('../src/model/teams/teamActivity.db')
const teamsDb = require('../src/model/teams/teams.db')
const access = require('../src/model/teams/teamAccess.model')
const settings = require('../src/model/settings/settings.model')
const activity = require('../src/model/teams/teamActivity.model')
let store
const saved = new Map()
function patch(mod, name, fn) {
if (!saved.has(mod)) saved.set(mod, new Map())
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
mod[name] = fn
}
function restore() {
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
saved.clear()
}
function stub() {
store = {
rows: [],
nextId: 1,
teams: [
{ id: 1, module_id: 'uo', external_id: 'g1', slug: 'the-guild', hidden: 0, status: 'active' },
{ id: 2, module_id: 'uo', external_id: 'g2', slug: 'hidden-guild', hidden: 1, status: 'active' },
],
allowed: new Set(), // userIds with member/grant access, keyed "teamId:userId"
}
patch(teamsDb, 'findActive', async (moduleId, externalId) =>
store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active'))
patch(teamsDb, 'findBySlug', async (slug) => store.teams.find((t) => t.slug === slug))
patch(access, 'forumAccess', async (teamId, userId) => ({
allowed: store.allowed.has(`${teamId}:${userId}`),
viaMembership: store.allowed.has(`${teamId}:${userId}`),
viaGrant: false,
isLeader: false,
}))
patch(activityDb, 'insert', async (item) => {
if (item.dedupeKey && store.rows.some((r) => r.team_id === item.teamId && r.dedupe_key === item.dedupeKey)) {
return false // the unique index doing its job
}
store.rows.push({
id: store.nextId++,
team_id: item.teamId,
source: item.source,
kind: item.kind,
summary: item.summary,
visibility: item.visibility,
actor_member_key: item.actorMemberKey,
actor_user_id: item.actorUserId,
payload: item.payload,
occurred_at: new Date(item.occurredAt),
dedupe_key: item.dedupeKey,
})
return true
})
patch(activityDb, 'page', async (teamId, visibilities, { limit, offset }) =>
store.rows
.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility))
.sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id)
.slice(offset, offset + limit))
patch(activityDb, 'count', async (teamId, visibilities) =>
store.rows.filter((r) => r.team_id === teamId && visibilities.includes(r.visibility)).length)
patch(activityDb, 'deleteOlderThan', async (days) => {
const cutoff = Date.now() - days * 86400_000
const before = store.rows.length
store.rows = store.rows.filter((r) => r.occurred_at.getTime() >= cutoff)
return before - store.rows.length
})
patch(activityDb, 'overCap', async (cap) => {
const byTeam = new Map()
for (const r of store.rows) byTeam.set(r.team_id, (byTeam.get(r.team_id) || 0) + 1)
return [...byTeam].filter(([, n]) => n > cap).map(([team_id, n]) => ({ team_id, n }))
})
patch(activityDb, 'trimToCap', async (teamId, cap) => {
const mine = store.rows
.filter((r) => r.team_id === teamId)
.sort((a, b) => b.occurred_at - a.occurred_at || b.id - a.id)
const keep = new Set(mine.slice(0, cap).map((r) => r.id))
const before = store.rows.length
store.rows = store.rows.filter((r) => r.team_id !== teamId || keep.has(r.id))
return before - store.rows.length
})
patch(settings, 'get', async () => null) // defaults
}
const item = (extra = {}) => ({ externalId: 'g1', kind: 'uo.thing', summary: 'A thing happened', ...extra })
beforeEach(stub)
afterEach(restore)
// ── What a module may write ────────────────────────────────────────────────
test('a pushed item lands against the team its external id names', async () => {
const stored = await activity.push('uo', [item()])
assert.equal(stored, 1)
assert.equal(store.rows[0].team_id, 1)
assert.equal(store.rows[0].source, 'uo')
})
test('a module cannot write into another module\'s team', async () => {
// 'other' owns no team with external id g1, so there is nothing to resolve —
// and no integer the module could have sent instead, which is the point of
// naming Teams by external id on this path.
const stored = await activity.push('other', [item()])
assert.equal(stored, 0)
assert.equal(store.rows.length, 0)
})
test('an unknown external id is dropped rather than raised', async () => {
const stored = await activity.push('uo', [item({ externalId: 'nope' })])
assert.equal(stored, 0)
})
test('visibility defaults to members, and an unknown value does not widen it', async () => {
await activity.push('uo', [item(), item({ visibility: 'everyone' }), item({ visibility: 'public' })])
assert.deepEqual(store.rows.map((r) => r.visibility), ['members', 'members', 'public'])
})
test('an item with no kind or no summary is dropped, and the rest of the batch still lands', async () => {
const stored = await activity.push('uo', [item({ kind: '' }), item({ summary: ' ' }), item()])
assert.equal(stored, 1)
assert.equal(store.rows.length, 1)
})
test('an over-long summary is truncated rather than losing the event', async () => {
await activity.push('uo', [item({ summary: 'x'.repeat(400) })])
assert.equal(store.rows[0].summary.length, 255)
})
test('a replayed batch with dedupe keys stores each item once', async () => {
const batch = [item({ dedupeKey: 'champ:77' }), item({ dedupeKey: 'champ:78' })]
await activity.push('uo', batch)
await activity.push('uo', batch) // the sidecar reconnect backfill
assert.equal(store.rows.length, 2)
})
test('items without a dedupe key are never collapsed into each other', async () => {
await activity.push('uo', [item(), item()])
assert.equal(store.rows.length, 2)
})
test('a push is never rejected for being malformed at the top level', async () => {
assert.equal(await activity.push('uo', null), 0)
assert.equal(await activity.push('uo', []), 0)
})
// ── What a caller may see ──────────────────────────────────────────────────
test('an anonymous caller gets public items only, and is told the scope', async () => {
await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })])
const feed = await activity.feedFor('the-guild', null)
assert.equal(feed.items.length, 1)
assert.equal(feed.items[0].visibility, 'public')
assert.equal(feed.scope, 'public')
// `total` is the caller's total, not the table's — otherwise paging lies.
assert.equal(feed.total, 1)
})
test('a member sees both, via the same resolver the forum uses', async () => {
await activity.push('uo', [item({ visibility: 'public' }), item({ visibility: 'members' })])
store.allowed.add('1:7')
const feed = await activity.feedFor('the-guild', 7)
assert.equal(feed.items.length, 2)
assert.equal(feed.scope, 'members')
})
test('an authenticated non-member is exactly an anonymous caller here', async () => {
await activity.push('uo', [item({ visibility: 'members' })])
const feed = await activity.feedFor('the-guild', 99)
assert.equal(feed.items.length, 0)
assert.equal(feed.scope, 'public')
})
test('a hidden team\'s feed does not answer the public, but does answer its members', async () => {
await activity.push('uo', [item({ externalId: 'g2', visibility: 'public' })])
assert.equal(await activity.feedFor('hidden-guild', null), null)
store.allowed.add('2:7')
const feed = await activity.feedFor('hidden-guild', 7)
assert.equal(feed.items.length, 1)
})
test('an unknown slug is not found rather than empty', async () => {
assert.equal(await activity.feedFor('no-such-team', null), null)
})
test('the rendered item carries the payload and never the actor identifiers', async () => {
await activity.push('uo', [item({
visibility: 'public', payload: { serial: '0x77' }, actorMemberKey: '0x40012ab3', actorUserId: 7,
})])
const feed = await activity.feedFor('the-guild', null)
assert.deepEqual(feed.items[0].payload, { serial: '0x77' })
assert.equal('actorMemberKey' in feed.items[0], false)
assert.equal('actorUserId' in feed.items[0], false)
})
// ── Core's own items ───────────────────────────────────────────────────────
test('core writes as source=core and public', async () => {
await activity.logCore({ teamId: 1, kind: activity.CORE_KINDS.MEMBER_JOINED, summary: 'Aldric joined' })
assert.equal(store.rows[0].source, 'core')
assert.equal(store.rows[0].visibility, 'public')
})
test('core refuses an item with nothing to say', async () => {
assert.equal(await activity.logCore({ teamId: 1, kind: 'core.x' }), false)
assert.equal(store.rows.length, 0)
})
// ── Retention ──────────────────────────────────────────────────────────────
test('the prune drops rows past the age horizon', async () => {
const old = Date.now() - 100 * 86400_000
await activity.push('uo', [item({ occurredAt: old }), item()])
const res = await activity.prune()
assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS)
assert.equal(res.byAge, 1)
assert.equal(store.rows.length, 1)
})
test('the prune trims a team back to the row cap, newest kept', async () => {
patch(settings, 'get', async (key) => (key === activity.CAP_KEY ? '3' : null))
const base = Date.now()
for (let i = 0; i < 6; i++) {
// eslint-disable-next-line no-await-in-loop
await activity.push('uo', [item({ summary: `event ${i}`, occurredAt: base + i * 1000 })])
}
const res = await activity.prune()
assert.equal(res.byCap, 3)
assert.deepEqual(store.rows.map((r) => r.summary), ['event 3', 'event 4', 'event 5'])
})
test('a zero or negative retention setting is rejected rather than emptying the feed', async () => {
patch(settings, 'get', async (key) => (key === activity.RETAIN_KEY ? '0' : null))
await activity.push('uo', [item()])
const res = await activity.prune()
assert.equal(res.days, activity.DEFAULT_RETAIN_DAYS)
assert.equal(store.rows.length, 1)
})
test('an unreadable settings table leaves the defaults standing', async () => {
patch(settings, 'get', async () => { throw new Error('pool down') })
const res = await activity.retentionConfig()
assert.deepEqual(res, { days: activity.DEFAULT_RETAIN_DAYS, cap: activity.DEFAULT_ROW_CAP })
})