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
418 lines
17 KiB
JavaScript
418 lines
17 KiB
JavaScript
// ── 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)
|
|
})
|