Files
website/server/test/eventPublic.test.js
wtclaude b5616f359c
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 39s
PR Checks / client-build (pull_request) Successful in 42s
PR Checks / server-tests (pull_request) Successful in 5m54s
feat(events): publish runId on a public calendar run entry (Rust D125)
A run entry on GET /public/events now names its run, the same id the
event page already publishes on each occurrence and `?run=` takes. A
Rust map marker carries core's run id and nothing else about its event,
so without this the app could only find the event by fetching every
event page.

A projected entry has no runId: nothing is committed to it. Rehearsals
and unlisted events stay absent from the calendar, so their markers
stay unlinked. The web calendar ignores the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-25 07:38:59 -05:00

522 lines
21 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 }))
// Mirrors the OVERLAP predicate the real statement uses: a run is in the window
// if its instant falls inside it, OR if it began before the window and is still
// live. A run is an interval, not an instant — see `eventRuns.db.listInWindow`.
runsDb.listInWindow = async ({ from, to, publicOnly = false }) =>
store.runs.filter((r) => {
const at = new Date(r.scheduled_for)
const startsInside = at >= from && at < to
const liveAcross = at < to && ['starting', 'running', 'paused', 'ending'].includes(r.status)
if (!startsInside && !liveAcross) 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', 'runId', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone',
'title',
])
})
test('a run entry names its run, and a projection names none', async () => {
// Rust phase 15, D125: a map marker carries core's run id, and the app finds
// the event it belongs to from this calendar. The id is the one the event page
// already publishes on each occurrence.
store.definitions[0].spec = {
...SPEC,
schedule: { kind: 'weekly', days: ['saturday'], time: '00:00' },
}
const result = await publicModel.calendar({ from: '2026-08-28', to: '2026-09-15', now: NOW })
const runs = result.entries.filter((e) => e.kind === 'run')
const projected = result.entries.filter((e) => e.kind === 'projected')
assert.equal(runs.length, 1)
assert.equal(runs[0].runId, store.runs[0].id)
assert.ok(projected.length > 0, 'the weekly schedule must forecast past the one run')
for (const entry of projected) assert.equal('runId' in entry, false)
const page = await publicModel.event('the-yew-invasion')
const occurrences = [page.event.current, page.event.next, ...page.event.upcoming, ...page.event.past]
assert.ok(occurrences.some((o) => o && o.runId === runs[0].runId))
})
test('the default window reaches back as well as forward', async () => {
// §I: this route is "upcoming, live and recent". The default used to start at
// `now`, which left no room for the third word — an event that finished an hour
// ago was already gone, so a visitor had nowhere to find the results of the
// thing they had just attended (Phase 16 walk).
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
const back = (NOW - new Date(result.window.from)) / 86_400_000
const forward = (new Date(result.window.to) - NOW) / 86_400_000
assert.equal(back, publicModel.DEFAULT_RECENT_DAYS)
assert.equal(forward, publicModel.DEFAULT_WINDOW_DAYS)
})
test('a run happening RIGHT NOW is on the calendar, whenever it started', async () => {
// The defect this pair was written for: the site said `live: true` on the
// event's own page and served `entries: []` from the calendar, because the
// window test read the START instant and a live run had already started. A run
// is an interval; the calendar asks which intervals overlap it.
store.runs = [
{
...run({
status: 'running',
// Well before any default window would begin.
scheduled_for: new Date('2026-08-01T00:00:00Z'),
ended_at: null,
}),
definition_title: 'The Yew Invasion',
definition_slug: 'the-yew-invasion',
},
]
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
const entry = result.entries.find((e) => e.kind === 'run')
assert.ok(entry, 'a live run must appear however long ago it began')
assert.equal(entry.live, true)
assert.equal(entry.status, 'live')
})
test('a run that finished inside the recent tail is still on the calendar', async () => {
store.runs = [
{
...run({
status: 'completed',
scheduled_for: new Date(NOW.getTime() - 2 * 86_400_000),
ended_at: new Date(NOW.getTime() - 2 * 86_400_000 + 3_600_000),
}),
definition_title: 'The Yew Invasion',
definition_slug: 'the-yew-invasion',
},
]
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
assert.equal(result.entries.filter((e) => e.kind === 'run').length, 1)
})
test('nothing is FORECAST into the recent tail', async () => {
// The tail is for what happened, and only the materialised half fills it. A
// projection into the past would advertise an occurrence that did not happen:
// one that WAS created is a real row and arrives as a run, and one that was not
// is a slot the runner has already gone past.
store.definitions = [
definition({
spec: {
...SPEC,
schedule: {
kind: 'weekly',
days: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
time: '20:00',
},
},
}),
]
store.runs = []
const result = await publicModel.calendar({ now: NOW })
assert.equal(result.ok, true)
const projected = result.entries.filter((e) => e.kind !== 'run')
assert.ok(projected.length > 0, 'a daily schedule must still forecast forwards')
for (const entry of projected) {
assert.ok(
new Date(entry.scheduledFor) >= NOW,
`forecast ${entry.scheduledFor} is before now — the tail must hold no projections`,
)
}
})
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)
})