Files
website/server/test/eventSchedule.test.js
wtclaude 4077c4e79e
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 30s
PR Checks / client-build (pull_request) Successful in 36s
PR Checks / server-tests (pull_request) Successful in 13m33s
feat(events): enablement, per-run caps and mayInvoke (Phase 6)
Two new tables — event_action_settings (the deployment switchboard) and
event_run_budget (what a run has spent and the most it may) — plus verified_at
and verified_by on event_versions. The whole authorisation decision moves behind
one function, events/authorize.js: role, enablement, cap, and the shard's own
switch named as the layer core deliberately does not duplicate.

Three routes, none moved: GET/PUT /admin/events/actions (admin in both
directions) and POST /admin/events/:id/verify (admin, editor — a dry run
dispatches nothing).

Four decisions, settled by the org lead 2026-09-03:

- The default-off line falls between inspect and change, not between notify and
  inspect. Read literally, §K shipped core.wait disabled. The same line is the
  role floor.
- The tightest cap wins where two actions spend one dimension, pinned into the
  run at creation with the action it came from.
- A refusal follows the step's on_failure and takes health to degraded — its own
  status and its own log kind, because a refusal is not an outage.
- The verify gate is enforced for scheduled starts only: a human pressing Start
  now is the review the gate exists to require.

Derived and flagged for review: a dry run fails rather than warns on a disabled
action or an over-cap plan, and the unattended path does not re-check the
starter's role.

+111 tests (1921/1847/73/1 — the one failure pre-existing and environmental),
including a 403 walk over the real router and two concurrent spends against one
cap on a real MariaDB. The live walk found two defects, both fixed here: the run
console route dropped the budget it was handed, and the role refusal used a
plural verb over a one-item list.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6t8mrAWhZU5vnyYgZTMtL
2026-09-03 05:50:58 -05:00

412 lines
17 KiB
JavaScript

// ── Expansion and the calendar (EVENTS_PLAN.md Phase 4) ────────────────────
//
// The phase's shipped claim: **a published definition with a recurrence produces
// occurrences on its own, and the calendar shows the ones that exist beside the
// ones that will.** The arithmetic underneath is proved separately in
// `eventRecurrence.test.js`; this file is about the two decisions the org lead
// took on 2026-09-02 and the properties they imply:
//
// • occurrences become REAL ROWS inside a fourteen-day horizon, and beyond it
// the calendar projects rather than materialising
// • a projection is never emitted for an instant a run already occupies — so
// the fortnight inside the horizon is not drawn twice, and a CANCELLED
// occurrence does not come back as a forecast
// • expansion looks forward from `now - grace` only, so an occurrence nobody
// could ever have seen is not invented retroactively
// • only `ready` definitions expand: publishing IS the schedule switch (§E)
// and archiving is how an operator turns one off
// • expansion is idempotent, because it runs every fifteen seconds for ever
//
// Stubbed at the `.db` layer, the shape `eventRunner.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 registries = require('../src/modules/registries')
const runner = require('../src/utils/eventRunner')
const calendarModel = require('../src/model/events/eventCalendar.model')
const definitionsDb = require('../src/model/events/eventDefinitions.db')
const runsDb = require('../src/model/events/eventRuns.db')
const stepsDb = require('../src/model/events/eventRunSteps.db')
const logDb = require('../src/model/events/eventRunLog.db')
// Phase 6: `runsModel.create` prices the version against the switchboard and
// seeds the run's budget, so expansion now reaches two more tables. Unstubbed
// they are a ten-second ECONNREFUSED per occurrence.
const settingsDb = require('../src/model/events/eventActionSettings.db')
const budgetDb = require('../src/model/events/eventRunBudget.db')
const versionsDb = require('../src/model/events/eventVersions.db')
const db = require('../src/utils/db')
after(() => db.close())
// A Tuesday. Chosen so a "friday" schedule has its first occurrence three days
// out — inside the horizon, but not today, which is what keeps "materialised"
// and "due" from being confusable in these fixtures.
const NOW = new Date('2026-09-01T12:00:00Z')
const SPEC = {
schedule: { kind: 'weekly', days: ['friday'], time: '20:00' },
phases: [{ key: 'main', label: 'Main', steps: [] }],
}
let store
const originals = {}
for (const [name, mod] of [
['definitionsDb', definitionsDb],
['runsDb', runsDb],
['stepsDb', stepsDb],
['logDb', logDb],
['versionsDb', versionsDb],
['settingsDb', settingsDb],
['budgetDb', budgetDb],
]) {
originals[name] = { mod, fns: { ...mod } }
}
const restoreOriginals = () => {
for (const { mod, fns } of Object.values(originals)) Object.assign(mod, fns)
}
const clone = (o) => JSON.parse(JSON.stringify(o))
/** One `ready` definition with a published version carrying `spec`. */
function addDefinition(id, overrides = {}) {
const definition = {
id,
title: `Event ${id}`,
slug: `event-${id}`,
state: 'ready',
timezone: 'UTC',
grace_seconds: 900,
concurrency_key: null,
current_version_id: id * 100,
series_id: null,
series_name: null,
series_slug: null,
spec: clone(SPEC),
...overrides,
}
store.definitions.set(id, definition)
store.versions.set(definition.current_version_id, {
id: definition.current_version_id,
definition_id: id,
version: 1,
spec: definition.spec,
// Verified by default (Phase 6). §K holds a scheduled occurrence of a version
// nobody has dry-run, so an unverified fixture would make every test in this
// file assert nothing about recurrence and everything about that one gate.
// The gate has its own test below, where an occurrence is what is being
// measured rather than what is in the way.
verified_at: new Date('2026-08-01T00:00:00Z'),
verified_by: 1,
...(overrides.version || {}),
})
return definition
}
function installStubs() {
store = { definitions: new Map(), versions: new Map(), runs: [], steps: [], log: [], nextRunId: 1 }
Object.assign(definitionsDb, {
findSchedulable: async () =>
[...store.definitions.values()]
.filter((d) => d.state === 'ready' && d.current_version_id)
.map((d) => ({ ...d, version_spec: store.versions.get(d.current_version_id)?.spec || null })),
getById: async (id) => store.definitions.get(id) || null,
list: async () => [...store.definitions.values()],
})
Object.assign(versionsDb, { getById: async (id) => store.versions.get(id) || null })
Object.assign(runsDb, {
materialise: async (run) => {
const at = new Date(run.scheduled_for).getTime()
// The unique index, in memory: one row per (definition, scope, instant).
const clash = store.runs.find(
(r) => r.definition_id === run.definition_id && r.scope === (run.scope || '') && new Date(r.scheduled_for).getTime() === at,
)
if (clash) return null
const id = store.nextRunId++
const definition = store.definitions.get(run.definition_id)
store.runs.push({
...run,
id,
scope: run.scope || '',
status: 'scheduled',
health: 'ok',
waiting_steps: 0,
definition_title: definition?.title,
definition_slug: definition?.slug,
series_id: definition?.series_id ?? null,
series_name: definition?.series_name ?? null,
series_slug: definition?.series_slug ?? null,
version_number: 1,
})
return id
},
getById: async (id) => store.runs.find((r) => r.id === id) || null,
findOccurrence: async (definitionId, scope, at) =>
store.runs.find(
(r) => r.definition_id === definitionId && r.scope === (scope || '') && new Date(r.scheduled_for).getTime() === new Date(at).getTime(),
) || null,
listInWindow: async ({ from, to, status = null, scope = null, seriesId = null }) =>
store.runs
.filter((r) => {
const at = new Date(r.scheduled_for).getTime()
if (at < new Date(from).getTime() || at >= new Date(to).getTime()) return false
if (status && r.status !== status) return false
if (scope !== null && scope !== undefined && r.scope !== scope) return false
if (seriesId && Number(r.series_id) !== Number(seriesId)) return false
return true
})
.sort((a, b) => new Date(a.scheduled_for) - new Date(b.scheduled_for)),
})
Object.assign(stepsDb, { materialisePhase: async () => [] })
Object.assign(logDb, { write: async (line) => { store.log.push(line); return 1 } })
// Phase 6. No stored switch anywhere in this file: an empty switchboard is a
// fresh deployment, and expansion is not what this file is measuring.
Object.assign(settingsDb, { byIds: async () => new Map(), get: async () => null })
Object.assign(budgetDb, { seed: async () => 0, forRun: async () => [] })
}
beforeEach(() => {
registries._reset()
registries.registerCore()
installStubs()
})
afterEach(restoreOriginals)
const instants = () => store.runs.map((r) => new Date(r.scheduled_for).toISOString()).sort()
// ── Expansion ──────────────────────────────────────────────────────────────
test('a weekly definition materialises exactly the occurrences inside the horizon', async () => {
addDefinition(1)
const created = await runner.expandSchedules(NOW)
// 1 September 2026 is a Tuesday. Fridays inside 14 days: the 4th and the 11th.
assert.equal(created, 2)
assert.deepEqual(instants(), ['2026-09-04T20:00:00.000Z', '2026-09-11T20:00:00.000Z'])
})
test('expansion is idempotent — running it again creates nothing', async () => {
// The property the whole design leans on: this runs every fifteen seconds for
// ever. `INSERT IGNORE` against the occurrence key is what makes that free,
// and a second call that created rows would be a duplicate event, not a
// duplicate row.
addDefinition(1)
assert.equal(await runner.expandSchedules(NOW), 2)
assert.equal(await runner.expandSchedules(NOW), 0)
assert.equal(await runner.expandSchedules(new Date(NOW.getTime() + 60_000)), 0)
assert.equal(store.runs.length, 2)
})
test('only `ready` definitions expand — publishing is the switch, archiving turns it off', async () => {
addDefinition(1, { state: 'draft' })
addDefinition(2, { state: 'archived' })
addDefinition(3, { state: 'ready' })
await runner.expandSchedules(NOW)
assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [3])
})
test('a draft edit cannot materialise anything — the VERSION spec is what expands', async () => {
// The definition's working copy says daily; the published version says weekly.
// A half-typed recurrence an author is midway through must never produce a run.
const definition = addDefinition(1)
definition.spec = {
schedule: { kind: 'weekly', days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], time: '20:00' },
phases: SPEC.phases,
}
await runner.expandSchedules(NOW)
assert.equal(store.runs.length, 2)
})
test('a manual definition expands to nothing at all', async () => {
addDefinition(1, { spec: { schedule: { kind: 'manual' }, phases: SPEC.phases } })
store.versions.get(100).spec = store.definitions.get(1).spec
assert.equal(await runner.expandSchedules(NOW), 0)
assert.equal(store.runs.length, 0)
})
test('an occurrence older than the grace window is never materialised at all', async () => {
// Not materialised-then-swept. A row nobody could ever have seen or cancelled
// is not history, and writing one would put a `missed` event on the calendar
// for a date on which this deployment had no such event. The horizon is what
// makes the missed sweep meaningful instead: a real outage finds rows already
// there, because they were written a fortnight early.
addDefinition(1, { grace_seconds: 900 })
// A Monday, three days after the Friday occurrence — far outside the grace.
await runner.expandSchedules(new Date('2026-09-07T12:00:00Z'))
assert.ok(!instants().includes('2026-09-04T20:00:00.000Z'))
})
test('an occurrence still inside the grace window IS materialised', async () => {
// The case this rule exists for: a definition published four minutes before
// its own first occurrence. `now - grace` is the window start, so the
// occurrence that has only just passed is still created and still startable.
addDefinition(1, { grace_seconds: 3600 })
await runner.expandSchedules(new Date('2026-09-04T20:10:00Z'))
assert.ok(instants().includes('2026-09-04T20:00:00.000Z'))
})
test('a DST-adjusted occurrence records WHY its clock reads oddly', async () => {
// Discovering daylight saving at 3am on the last Sunday in October is the
// failure this line exists to prevent.
addDefinition(1, {
timezone: 'Europe/Berlin',
spec: { schedule: { kind: 'weekly', days: ['sunday'], time: '02:30' }, phases: SPEC.phases },
})
store.versions.get(100).spec = store.definitions.get(1).spec
await runner.expandSchedules(new Date('2026-03-22T12:00:00Z'))
const adjusted = store.log.find((l) => l.detail?.dstAdjusted)
assert.equal(adjusted.detail.dstAdjusted, 'gap')
assert.equal(adjusted.detail.timezone, 'Europe/Berlin')
assert.ok(instants().includes('2026-03-29T01:00:00.000Z'))
})
test('a definition whose spec is nonsense is skipped, and the sweep carries on', async () => {
// A spec written straight into the database with a shape the validator would
// have refused is a bad row, not a bad tick.
addDefinition(1, { spec: { schedule: { kind: 'weekly', days: ['froday'], time: '20:00' }, phases: SPEC.phases } })
store.versions.get(100).spec = store.definitions.get(1).spec
addDefinition(2)
const created = await runner.expandSchedules(NOW)
assert.equal(created, 2)
assert.deepEqual([...new Set(store.runs.map((r) => r.definition_id))], [2])
})
test('every materialised occurrence is marked as coming from the schedule', async () => {
// `started_by` is NULL for a scheduled occurrence and for one an admin started
// whose account has since gone, so the log is the only place the two are told
// apart.
addDefinition(1)
await runner.expandSchedules(NOW)
const created = store.log.filter((l) => l.kind === 'run.created' && l.detail?.source)
assert.equal(created.length, 2)
for (const line of created) {
assert.equal(line.detail.source, 'schedule')
assert.equal(line.detail.by, null)
}
})
// ── The calendar ───────────────────────────────────────────────────────────
test('inside the horizon the calendar shows runs; beyond it, projections', async () => {
addDefinition(1)
await runner.expandSchedules(NOW)
const result = await calendarModel.calendar({
from: new Date('2026-09-01T00:00:00Z'),
to: new Date('2026-10-01T00:00:00Z'),
now: NOW,
})
const kinds = result.entries.map((e) => `${e.kind} ${new Date(e.scheduledFor).toISOString().slice(0, 10)}`)
assert.deepEqual(kinds, [
'run 2026-09-04',
'run 2026-09-11',
'projected 2026-09-18',
'projected 2026-09-25',
])
// The forecast is arithmetic and says so: no row, nothing to open.
for (const entry of result.entries.filter((e) => e.kind === 'projected')) {
assert.equal(entry.runId, null)
assert.equal(entry.status, null)
}
})
test('a projection is never drawn over an instant a run already occupies', async () => {
addDefinition(1)
await runner.expandSchedules(NOW)
const result = await calendarModel.calendar({
from: new Date('2026-09-01T00:00:00Z'),
to: new Date('2026-09-15T00:00:00Z'),
now: NOW,
})
assert.equal(result.entries.length, 2)
assert.ok(result.entries.every((e) => e.kind === 'run'))
})
test('a CANCELLED occurrence does not come back as a forecast', async () => {
// The same rule, and the case it earns its keep on. An operator who called an
// event off must not find it on the calendar again ten seconds later looking
// like it is still coming.
addDefinition(1)
await runner.expandSchedules(NOW)
store.runs[0].status = 'cancelled'
const result = await calendarModel.calendar({
from: new Date('2026-09-01T00:00:00Z'),
to: new Date('2026-09-15T00:00:00Z'),
now: NOW,
})
const onTheDay = result.entries.filter((e) => new Date(e.scheduledFor).toISOString().startsWith('2026-09-04'))
assert.equal(onTheDay.length, 1)
assert.equal(onTheDay[0].kind, 'run')
assert.equal(onTheDay[0].status, 'cancelled')
})
test('a status filter suppresses projections, because a forecast has no status', async () => {
addDefinition(1)
await runner.expandSchedules(NOW)
const result = await calendarModel.calendar({
from: new Date('2026-09-01T00:00:00Z'),
to: new Date('2026-10-01T00:00:00Z'),
status: 'scheduled',
now: NOW,
})
assert.ok(result.entries.every((e) => e.kind === 'run'))
assert.equal(result.entries.length, 2)
})
test('a series filter narrows runs and projections alike', async () => {
addDefinition(1, { series_id: 7, series_name: 'Royal Spy Mission' })
addDefinition(2, { series_id: 9, series_name: 'Something Else' })
await runner.expandSchedules(NOW)
const result = await calendarModel.calendar({
from: new Date('2026-09-01T00:00:00Z'),
to: new Date('2026-10-01T00:00:00Z'),
seriesId: 7,
now: NOW,
})
assert.ok(result.entries.length > 2)
assert.ok(result.entries.every((e) => e.seriesName === 'Royal Spy Mission'))
assert.ok(result.entries.some((e) => e.kind === 'projected'))
})
test('the window is bounded, inverted windows are refused, and the horizon is reported', async () => {
const wide = await calendarModel.calendar({
from: new Date('2026-01-01T00:00:00Z'),
to: new Date('2027-01-01T00:00:00Z'),
now: NOW,
})
assert.equal(wide.ok, false)
assert.equal(wide.status, 400)
assert.match(wide.errors.join(' '), /at most 92 days/)
const inverted = await calendarModel.calendar({
from: new Date('2026-09-10T00:00:00Z'),
to: new Date('2026-09-01T00:00:00Z'),
now: NOW,
})
assert.equal(inverted.ok, false)
const fine = await calendarModel.calendar({
from: new Date('2026-09-01T00:00:00Z'),
to: new Date('2026-09-15T00:00:00Z'),
horizonDays: 14,
now: NOW,
})
assert.equal(fine.ok, true)
assert.equal(fine.horizon.toISOString(), '2026-09-15T12:00:00.000Z')
})