Files
website/server/test/eventPublic.test.js
wtclaude 6dd4e5e3eb
Some checks failed
PR Checks / client-build (pull_request) Successful in 34s
PR Checks / bot-tests (pull_request) Successful in 33s
PR Checks / server-tests (pull_request) Failing after 5m47s
fix(events): the public calendar, a stranded revert, and three dropped facts (Phase 16a)
Three defects the acceptance walk found in shipped code.

**The public calendar showed neither what is live nor what is recent.** §I says
`GET /public/events` is "the calendar: upcoming, **live** and **recent**". Built,
it was upcoming only: `listInWindow` filtered on `scheduled_for >= from` alone and
the shipped page asks for no window at all, so it took the default of now → +31d.
A run that began five minutes ago and has three hours to go was absent; so was one
that ended an hour ago. The site contradicted itself — `live: true` on
`/site/events/<slug>` while `/site/events` served `entries: []`.

A run is an interval, not an instant. `listInWindow` now matches a run whose
occupied interval OVERLAPS the window, which fixes the admin calendar's identical
hole (a run that started last Sunday and is still going was missing from "this
week"), and the public default reaches `DEFAULT_RECENT_DAYS` back so "recent" has
somewhere to live. Forecasts are still computed from `now`, never from the tail:
a projection into the past would advertise an occurrence that did not happen.

**A resource left `reverting` by a crash was never reclaimed.** `claimRevert`'s
comment said `reverting` is not claimable "exactly as a step with a live claim is"
— but a step's claim carries `claim_expires_at` and is reclaimed when the lease
lapses, and a resource in `reverting` had no expiry and nothing released it. A
process killed mid-teardown stranded the row for good: the sweep skipped it every
15s for ever, `cleanup_status` never left `pending`, and `POST …/cleanup` — the
recourse §I names — answered 200 and did nothing, because it claims through the
same function. On the rig it stranded a lease, which then BLOCKED the next run of
the same event from taking that value until the shard's own deadline lapsed.

The stale test is `updated_at`, which for a `reverting` row is exactly when the
claim was taken, so no column is added. `updated_at` is re-stamped explicitly and
that is load-bearing rather than tidy: this connector sends `CLIENT_FOUND_ROWS`,
so without the write a second claimer would still match the row. `revert_attempts`
is untouched — a stale claim is a process that died, not an attempt that failed.

**Three facts every event announcement computed and none could use.**
`announce.js` `baseFor()` puts `summary`, `seriesName` and `timezone` on all seven
`event.*` payloads, but four triggers declared none of them and a fifth declared
one, so `validatePayload` dropped them, they were absent from the variable list an
author picks from, and every emit logged `emit carried undeclared variables` at
DEBUG. They are now one shared `EVENT_AMBIENT` declaration spread into all seven,
with the per-trigger copies removed so the seven cannot drift.

Verified against a real ServUO + sidecar + website rig: the public page now shows
a live run as "Happening now" beside recent finished ones (it showed nothing at
all before), and a lease stranded by a real mid-teardown crash was reclaimed
within one sweep, taking `cleanup_status` from `pending` to `complete`.

The three `claimRevert` tests live in `eventRunnerSql.test.js` against a real
MariaDB, because every part of the answer is the server's — `NOW() - INTERVAL`,
`ON UPDATE`, and above all what `affectedRows` counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-09 08:28:17 -05:00

500 lines
20 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', 'scheduledFor', 'seriesName', 'seriesSlug', 'slug', 'status', 'timezone', 'title',
])
})
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)
})