feat(events): the public calendar, event pages and participation history (Phase 14a)
The anonymous surface an event was always for: GET /public/events, /public/events/:slug and /public/events/series/:slug, plus GET /player/events/history, and the four screens over them. Four org-lead decisions taken up front: split Phase 14 into 14a (website) and 14b (the app); add a `listed` flag rather than letting `state` mean both schedulable and announced; put the `events` capability string in the version block rather than publishing core as a pseudo-module; and drop "venue" from the spec rather than adding a field nothing had ever built. `listed` is announcement, not permission. Publishing is what makes a definition runnable, so without a separate flag a surprise event would have to be advertised in order to be allowed to happen. It is a column, a switch in Phase 13's editor, and three SQL predicates -- never a filter applied after a read, which works exactly as well until the first caller that forgets. The public shapes are a projection, and the projection is the security boundary: nothing is spread, so a column added to event_runs next year does not ride out through it. The spec, health, cleanup, claims, errors and member_key are all absent by construction. The six public event triggers gained `eventUrl` (version 1 -> 2), carrying ?run= because the page lives at the definition's slug while every trigger is about one occurrence. notify.event-started gained the button, at seedVersion 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
417
server/test/eventPublic.test.js
Normal file
417
server/test/eventPublic.test.js
Normal file
@@ -0,0 +1,417 @@
|
||||
// ── The public event surface (EVENTS_PLAN.md Phase 14a) ────────────────────
|
||||
//
|
||||
// The phase's shipped claim: **a visitor with no account sees the calendar, one
|
||||
// event's page and an arc, and sees nothing an operator did not announce.**
|
||||
//
|
||||
// What is worth testing here is almost entirely the second half. The reads
|
||||
// themselves are joins; the decisions are about what is absent:
|
||||
//
|
||||
// • a rehearsal, an unlisted definition and a draft are absent from every
|
||||
// surface, and absent the same way — a 404 that cannot be told from a slug
|
||||
// that never existed
|
||||
// • the plan behind an event (phases, steps, actions, params) is never
|
||||
// published; a live run carries the LABEL of its phase and nothing else
|
||||
// • `failed` and `missed` are published as `cancelled`, because the difference
|
||||
// between them is about the deployment rather than about the event
|
||||
// • `member_key` never leaves the server, even on a results table
|
||||
// • a participant's own history obeys the same two exclusions as the calendar,
|
||||
// so attending an unannounced event does not disclose that it exists
|
||||
//
|
||||
// Stubbed at the `.db` layer, the shape `eventSchedule.test.js` uses.
|
||||
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const publicModel = require('../src/model/events/eventPublic.model')
|
||||
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||
const runsDb = require('../src/model/events/eventRuns.db')
|
||||
const seriesDb = require('../src/model/events/eventSeries.db')
|
||||
const versionsDb = require('../src/model/events/eventVersions.db')
|
||||
const participantsDb = require('../src/model/events/eventRunParticipants.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
const NOW = new Date('2026-09-01T12:00:00Z')
|
||||
|
||||
const SPEC = {
|
||||
schedule: { kind: 'manual' },
|
||||
phases: [
|
||||
{ id: 'muster', label: 'The muster', steps: [{ id: 's1', action: 'core.announce' }] },
|
||||
{ id: 'assault', label: 'The assault', steps: [] },
|
||||
],
|
||||
}
|
||||
|
||||
const originals = {}
|
||||
for (const [name, mod] of [
|
||||
['definitionsDb', definitionsDb],
|
||||
['runsDb', runsDb],
|
||||
['seriesDb', seriesDb],
|
||||
['versionsDb', versionsDb],
|
||||
['participantsDb', participantsDb],
|
||||
]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
}
|
||||
const restoreOriginals = () => {
|
||||
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
|
||||
}
|
||||
|
||||
let store
|
||||
|
||||
const definition = (over = {}) => ({
|
||||
id: 1,
|
||||
title: 'The Yew Invasion',
|
||||
slug: 'the-yew-invasion',
|
||||
summary: 'Orcish warbands are massing north of Yew.',
|
||||
body: '<p>They came at dusk.</p>',
|
||||
image_url: null,
|
||||
state: 'ready',
|
||||
listed: true,
|
||||
timezone: 'America/New_York',
|
||||
series_id: null,
|
||||
series_name: null,
|
||||
series_slug: null,
|
||||
current_version_id: 100,
|
||||
spec: SPEC,
|
||||
...over,
|
||||
})
|
||||
|
||||
const run = (over = {}) => ({
|
||||
id: 3692,
|
||||
definition_id: 1,
|
||||
version_id: 100,
|
||||
scope: '',
|
||||
status: 'completed',
|
||||
// Every one of these is a field the public shapes must NOT carry. They are on
|
||||
// the fixture on purpose: a `{ ...run }` anywhere in the model would publish
|
||||
// them, and the assertions below are what would catch it.
|
||||
health: 'degraded',
|
||||
cleanup_status: 'incomplete',
|
||||
claimed_by: 'worker-3',
|
||||
claim_expires_at: new Date(),
|
||||
last_error: 'sidecar responded 503',
|
||||
current_phase: 'assault',
|
||||
scheduled_for: new Date('2026-08-29T00:00:00Z'),
|
||||
started_at: new Date('2026-08-29T00:00:05Z'),
|
||||
ended_at: new Date('2026-08-29T01:30:00Z'),
|
||||
timezone: 'America/New_York',
|
||||
rehearsal: false,
|
||||
results_published_at: new Date('2026-08-29T02:00:00Z'),
|
||||
...over,
|
||||
})
|
||||
|
||||
function installStubs() {
|
||||
definitionsDb.getPublicBySlug = async (slug) => {
|
||||
const d = store.definitions.find((x) => x.slug === slug)
|
||||
return d && d.state === 'ready' && d.listed ? d : undefined
|
||||
}
|
||||
definitionsDb.listPublicBySeries = async (seriesId) =>
|
||||
store.definitions.filter((d) => d.series_id === seriesId && d.state === 'ready' && d.listed)
|
||||
definitionsDb.findSchedulable = async ({ listedOnly = false } = {}) =>
|
||||
store.definitions
|
||||
.filter((d) => d.state === 'ready' && (!listedOnly || d.listed))
|
||||
.map((d) => ({ ...d, version_spec: d.spec }))
|
||||
|
||||
runsDb.listInWindow = async ({ from, to, publicOnly = false }) =>
|
||||
store.runs.filter((r) => {
|
||||
const at = new Date(r.scheduled_for)
|
||||
if (at < from || at >= to) return false
|
||||
if (!publicOnly) return true
|
||||
const d = store.definitions.find((x) => x.id === r.definition_id)
|
||||
return !r.rehearsal && d && d.listed && d.state !== 'archived'
|
||||
})
|
||||
runsDb.listPublicForDefinition = async (id) =>
|
||||
store.runs
|
||||
.filter((r) => r.definition_id === id && !r.rehearsal)
|
||||
.sort((a, b) => new Date(b.scheduled_for) - new Date(a.scheduled_for))
|
||||
|
||||
seriesDb.getById = async (id) => store.series.find((s) => s.id === id) || null
|
||||
seriesDb.getBySlug = async (slug) => store.series.find((s) => s.slug === slug) || null
|
||||
versionsDb.getById = async (id) => (id === 100 ? { id, spec: SPEC } : null)
|
||||
participantsDb.listForRun = async () => store.participants
|
||||
participantsDb.listForUser = async () => store.history
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const d = definition()
|
||||
store = {
|
||||
definitions: [d],
|
||||
// The joined shape `listInWindow` answers with.
|
||||
runs: [{ ...run(), definition_title: d.title, definition_slug: d.slug }],
|
||||
series: [],
|
||||
participants: [],
|
||||
history: [],
|
||||
}
|
||||
installStubs()
|
||||
})
|
||||
afterEach(restoreOriginals)
|
||||
|
||||
// ── The calendar ───────────────────────────────────────────────────────────
|
||||
|
||||
test('a calendar entry carries no operational field at all', async () => {
|
||||
const result = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
|
||||
assert.equal(result.ok, true)
|
||||
const [entry] = result.entries
|
||||
assert.equal(entry.title, 'The Yew Invasion')
|
||||
// The whole security property of this file, asserted positively: the entry has
|
||||
// exactly these keys and gaining one is a deliberate act.
|
||||
assert.deepEqual(Object.keys(entry).sort(), [
|
||||
'kind', 'live', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
|
||||
])
|
||||
})
|
||||
|
||||
test('the calendar defaults to a month from now when no window is given', async () => {
|
||||
const result = await publicModel.calendar({ now: NOW })
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(new Date(result.window.from).getTime(), NOW.getTime())
|
||||
const days = (new Date(result.window.to) - new Date(result.window.from)) / 86_400_000
|
||||
assert.equal(days, publicModel.DEFAULT_WINDOW_DAYS)
|
||||
})
|
||||
|
||||
test('a window wider than the cap is refused rather than served slowly', async () => {
|
||||
const result = await publicModel.calendar({ from: '2026-01-01', to: '2026-12-31', now: NOW })
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 400)
|
||||
})
|
||||
|
||||
test('a projection is not emitted for an instant a run already occupies', async () => {
|
||||
// The definition recurs weekly on the Saturday its one run already sits on.
|
||||
store.definitions[0].spec = {
|
||||
...SPEC,
|
||||
schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
|
||||
}
|
||||
const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-08-31', now: NOW })
|
||||
const at = result.entries.filter(
|
||||
(e) => new Date(e.scheduledFor).getTime() === new Date('2026-08-29T00:00:00Z').getTime(),
|
||||
)
|
||||
assert.equal(at.length, 1)
|
||||
assert.equal(at[0].kind, 'run')
|
||||
})
|
||||
|
||||
// ── What the public never sees ─────────────────────────────────────────────
|
||||
|
||||
test('an unlisted event is absent from the calendar and 404s on its own page', async () => {
|
||||
store.definitions[0].listed = false
|
||||
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
|
||||
assert.deepEqual(cal.entries, [])
|
||||
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.ok, false)
|
||||
assert.equal(page.status, 404)
|
||||
})
|
||||
|
||||
test('a draft answers exactly as an unlisted one does — indistinguishable from no such slug', async () => {
|
||||
store.definitions[0].state = 'draft'
|
||||
const draft = await publicModel.event('the-yew-invasion')
|
||||
const missing = await publicModel.event('no-such-event')
|
||||
assert.deepEqual(draft, missing)
|
||||
})
|
||||
|
||||
test('a rehearsal is absent from the calendar and from an event page', async () => {
|
||||
store.runs[0].rehearsal = true
|
||||
const cal = await publicModel.calendar({ from: '2026-08-01', to: '2026-09-15', now: NOW })
|
||||
assert.deepEqual(cal.entries, [])
|
||||
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.ok, true)
|
||||
assert.deepEqual(page.event.past, [])
|
||||
})
|
||||
|
||||
test('an occurrence publishes no health, no cleanup state, no claim and no error', async () => {
|
||||
store.runs[0].status = 'running'
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
const occurrence = page.event.current
|
||||
for (const leaked of ['health', 'cleanupStatus', 'claimedBy', 'claimExpiresAt', 'lastError', 'versionId']) {
|
||||
assert.equal(occurrence[leaked], undefined, `${leaked} must not be published`)
|
||||
}
|
||||
assert.equal(JSON.stringify(page).includes('sidecar responded 503'), false)
|
||||
})
|
||||
|
||||
test('a live run carries its phase LABEL, and never the spec behind it', async () => {
|
||||
store.runs[0].status = 'running'
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.current.phase, 'The assault')
|
||||
// The step ids in the fixture spec are the tell: if the spec were published
|
||||
// anywhere in this answer, this would find it.
|
||||
assert.equal(JSON.stringify(page).includes('core.announce'), false)
|
||||
})
|
||||
|
||||
test('a phase the pinned version does not name renders nothing rather than an id', async () => {
|
||||
store.runs[0].status = 'running'
|
||||
store.runs[0].current_phase = 'a-phase-since-renamed'
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.current.phase, null)
|
||||
})
|
||||
|
||||
test('failed and missed are both published as cancelled', async () => {
|
||||
for (const status of ['failed', 'missed']) {
|
||||
store.runs[0].status = status
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.past[0].status, 'cancelled', status)
|
||||
}
|
||||
})
|
||||
|
||||
test('paused is published as live — an operator holding a run is not a public state', async () => {
|
||||
store.runs[0].status = 'paused'
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.current.status, 'live')
|
||||
assert.equal(page.event.live, true)
|
||||
})
|
||||
|
||||
// ── Which side of now an occurrence falls on ───────────────────────────────
|
||||
//
|
||||
// Both of these were found by the browser walk, and both are the same mistake:
|
||||
// the split reading a STATUS where it should read a clock. Dates here are
|
||||
// relative to the real clock, because `event()` asks `Date.now()` — a run
|
||||
// "next Friday" has to still be next Friday when this runs.
|
||||
|
||||
const inDays = (n) => new Date(Date.now() + n * 86_400_000)
|
||||
|
||||
test('a cancelled occurrence in the FUTURE is what is coming, not what happened', async () => {
|
||||
// It was announced and it has been called off, and "next Friday is off" is
|
||||
// exactly what somebody checking the calendar came to find out. Filing it
|
||||
// under "previously" tells them it already happened, which is the one thing
|
||||
// certainly untrue about it.
|
||||
store.runs = [
|
||||
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
|
||||
{ ...run({ id: 2, status: 'scheduled', scheduled_for: inDays(11), ended_at: null, results_published_at: null }) },
|
||||
]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.deepEqual(page.event.upcoming.map((o) => o.runId), [1, 2])
|
||||
assert.deepEqual(page.event.past, [])
|
||||
})
|
||||
|
||||
test('`next` skips a cancelled occurrence even though it is listed as coming', async () => {
|
||||
// The headline answers "when is the next one", and a cancelled occurrence is
|
||||
// not one. An event whose only future occurrence was called off has no `next`
|
||||
// and says so, while the cancellation is still listed below.
|
||||
store.runs = [
|
||||
{ ...run({ id: 1, status: 'cancelled', scheduled_for: inDays(4), ended_at: null, results_published_at: null }) },
|
||||
]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.next, null)
|
||||
assert.equal(page.event.upcoming.length, 1)
|
||||
})
|
||||
|
||||
test('a scheduled occurrence whose moment has gone by is in the past', async () => {
|
||||
// The other direction of the same rule: the runner had not reached it, so its
|
||||
// status still says `scheduled` while the evening it named is over.
|
||||
store.runs = [
|
||||
{ ...run({ id: 1, status: 'scheduled', scheduled_for: inDays(-3), ended_at: null, results_published_at: null }) },
|
||||
]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.deepEqual(page.event.upcoming, [])
|
||||
assert.deepEqual(page.event.past.map((o) => o.runId), [1])
|
||||
})
|
||||
|
||||
// ── Results ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a results row publishes the score and the rank and never the member key', async () => {
|
||||
store.participants = [
|
||||
{ id: 1, member_key: 'serial:0x40001234', user_id: 7, score: 1420, rank_at: 1, meta: { name: 'Aldric' } },
|
||||
]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.results.participants[0].name, 'Aldric')
|
||||
assert.equal(page.event.results.participants[0].rank, 1)
|
||||
assert.equal(JSON.stringify(page).includes('0x40001234'), false)
|
||||
assert.equal(JSON.stringify(page).includes('user_id'), false)
|
||||
})
|
||||
|
||||
test('an unpublished results table is absent rather than empty', async () => {
|
||||
store.runs[0].results_published_at = null
|
||||
store.participants = [{ id: 1, member_key: 'k', score: 10, rank_at: null, meta: null }]
|
||||
const page = await publicModel.event('the-yew-invasion')
|
||||
assert.equal(page.event.results, null)
|
||||
})
|
||||
|
||||
test('?run= selects which occurrence the results are about', async () => {
|
||||
const older = {
|
||||
...run({ id: 3600, scheduled_for: new Date('2026-08-22T00:00:00Z') }),
|
||||
definition_title: 'The Yew Invasion',
|
||||
definition_slug: 'the-yew-invasion',
|
||||
}
|
||||
store.runs.push(older)
|
||||
const page = await publicModel.event('the-yew-invasion', { runId: '3600' })
|
||||
assert.equal(page.event.results.runId, 3600)
|
||||
})
|
||||
|
||||
test('a run id belonging to no occurrence of this event renders the page anyway', async () => {
|
||||
// A stale link in a months-old mail should land on the event it was about, not
|
||||
// on a dead end.
|
||||
const page = await publicModel.event('the-yew-invasion', { runId: '999999' })
|
||||
assert.equal(page.ok, true)
|
||||
assert.equal(page.event.slug, 'the-yew-invasion')
|
||||
})
|
||||
|
||||
// ── The arc ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a series with nothing listed in it is a 404, not an empty page', async () => {
|
||||
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: null }]
|
||||
store.definitions[0].series_id = 5
|
||||
store.definitions[0].listed = false
|
||||
const arc = await publicModel.series('the-yew-campaign')
|
||||
assert.equal(arc.ok, false)
|
||||
assert.equal(arc.status, 404)
|
||||
})
|
||||
|
||||
test('an arc lists its listed events and nothing about their plans', async () => {
|
||||
store.series = [{ id: 5, name: 'The Yew Campaign', slug: 'the-yew-campaign', description: 'An arc.' }]
|
||||
store.definitions[0].series_id = 5
|
||||
const arc = await publicModel.series('the-yew-campaign')
|
||||
assert.equal(arc.ok, true)
|
||||
assert.deepEqual(Object.keys(arc.series.events[0]).sort(), ['imageUrl', 'slug', 'summary', 'title'])
|
||||
})
|
||||
|
||||
// ── Participation history ──────────────────────────────────────────────────
|
||||
|
||||
test('history publishes the rank as null until results were published', async () => {
|
||||
store.history = [
|
||||
{
|
||||
id: 9,
|
||||
run_id: 3692,
|
||||
score: 1420,
|
||||
rank_at: null,
|
||||
joined_at: new Date(),
|
||||
meta: null,
|
||||
scheduled_for: new Date('2026-08-29T00:00:00Z'),
|
||||
started_at: null,
|
||||
ended_at: null,
|
||||
status: 'completed',
|
||||
scope: '',
|
||||
timezone: 'UTC',
|
||||
results_published_at: null,
|
||||
definition_title: 'The Yew Invasion',
|
||||
definition_slug: 'the-yew-invasion',
|
||||
series_name: null,
|
||||
series_slug: null,
|
||||
},
|
||||
]
|
||||
const result = await publicModel.history(7)
|
||||
assert.equal(result.entries[0].rank, null)
|
||||
assert.equal(result.entries[0].resultsPublishedAt, null)
|
||||
assert.equal(result.entries[0].score, 1420)
|
||||
})
|
||||
|
||||
test('history publishes no member key', async () => {
|
||||
store.history = [
|
||||
{
|
||||
id: 9,
|
||||
run_id: 3692,
|
||||
member_key: 'serial:0x40001234',
|
||||
score: 1,
|
||||
rank_at: 1,
|
||||
joined_at: new Date(),
|
||||
meta: null,
|
||||
scheduled_for: new Date(),
|
||||
status: 'completed',
|
||||
timezone: 'UTC',
|
||||
definition_title: 't',
|
||||
definition_slug: 's',
|
||||
},
|
||||
]
|
||||
const result = await publicModel.history(7)
|
||||
assert.equal(JSON.stringify(result).includes('0x40001234'), false)
|
||||
})
|
||||
Reference in New Issue
Block a user