feat(events): schedule, recurrence and the calendar (Phase 4)
The four closed recurrence shapes computed in the definition's own IANA zone, a fourteen-day materialisation horizon with projections beyond it, series as a managed thing, and the admin calendar that replaces the plugin this feature exists to replace. An event now happens on its own. No schema change: Phase 1 built every column this needed. - events/recurrence.js is the ONE place an occurrence is computed, so the runner's expansion and the calendar's forecast cannot disagree. No date library added — Node ships the tzdata one would vendor, behind Intl. - The runner's materialise leg is now two halves: expand, then sweep. The window starts at `now - grace`, so an occurrence nobody could have seen is never invented retroactively; the horizon is what makes the missed sweep mean anything for a recurrence. - Publishing is the schedule switch and archiving turns it off, and publishing re-pins every occurrence that has not started. - A projection is never drawn over an instant a run occupies, so a cancelled occurrence does not reappear as a forecast. 54 new tests, incl. the DST fixture set the plan asked for and three new statements proved against a real MariaDB. Suite 1768/1711/56 skipped/1 fail (pre-existing CRLF). Walked end to end on the local review stack. Docs: RunicGateway/docs#PENDING Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
273
server/test/eventRecurrence.test.js
Normal file
273
server/test/eventRecurrence.test.js
Normal file
@@ -0,0 +1,273 @@
|
||||
// ── Occurrence arithmetic (EVENTS.md §E, Phase 4) ──────────────────────────
|
||||
//
|
||||
// The plan asks for DST-crossing cases as EXPLICIT FIXTURES, and this file is
|
||||
// them. The reason it is worth a test file of its own is that every failure here
|
||||
// is silent in production: an event computed an hour off, or dropped for one
|
||||
// week a year, looks exactly like an event that happened correctly until an
|
||||
// operator is standing in the wrong place at the wrong time.
|
||||
//
|
||||
// The zone data is Node's own tzdata behind `Intl`, so these fixtures assert
|
||||
// against the real transitions rather than against a hand-written offset table:
|
||||
//
|
||||
// • Europe/Berlin, 2026-03-29 — CET (+1) to CEST (+2). 02:00 to 03:00 does not
|
||||
// exist. A weekly 02:30 event is the case the org lead decided.
|
||||
// • Europe/Berlin, 2026-10-25 — CEST (+2) back to CET (+1). 02:00 to 03:00
|
||||
// happens twice.
|
||||
// • Asia/Kolkata — +05:30, no DST at all, and a half-hour offset, which is
|
||||
// what catches an implementation that assumed whole hours.
|
||||
// • Australia/Lord_Howe — a THIRTY-MINUTE DST shift, which is what catches one
|
||||
// that assumed the gap is always an hour.
|
||||
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const r = require('../src/events/recurrence')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
/** What an instant reads as on the wall in a zone — the assertion that matters. */
|
||||
const wall = (zone, at) => {
|
||||
const p = r.wallPartsAt(zone, at instanceof Date ? at.getTime() : at)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${p.y}-${pad(p.m)}-${pad(p.d)} ${pad(p.h)}:${pad(p.mi)}`
|
||||
}
|
||||
|
||||
const walls = (occurrences, zone) => occurrences.map((o) => wall(zone, o.at))
|
||||
|
||||
// ── The rule the whole feature rests on ────────────────────────────────────
|
||||
|
||||
test('a weekly event keeps its LOCAL time across a DST boundary', () => {
|
||||
// The single most important assertion in this file. Friday 20:00 in Berlin is
|
||||
// 19:00 UTC in winter and 18:00 UTC in summer, and it is 20:00 on the wall on
|
||||
// every one of those Fridays. A recurrence computed in UTC would put half the
|
||||
// year an hour out, which is exactly what §E forbids.
|
||||
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
|
||||
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
|
||||
|
||||
assert.deepEqual(walls(found, 'Europe/Berlin'), [
|
||||
'2026-03-20 20:00',
|
||||
'2026-03-27 20:00',
|
||||
'2026-04-03 20:00',
|
||||
'2026-04-10 20:00',
|
||||
])
|
||||
// And the UTC instants really did move, which is what proves the zone was
|
||||
// consulted rather than the arithmetic accidentally agreeing.
|
||||
assert.equal(found[1].at.toISOString(), '2026-03-27T19:00:00.000Z')
|
||||
assert.equal(found[2].at.toISOString(), '2026-04-03T18:00:00.000Z')
|
||||
})
|
||||
|
||||
test('a weekly event keeps its local time across the October transition too', () => {
|
||||
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
|
||||
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 20), Date.UTC(2026, 10, 7))
|
||||
assert.deepEqual(walls(found, 'Europe/Berlin'), [
|
||||
'2026-10-23 20:00',
|
||||
'2026-10-30 20:00',
|
||||
'2026-11-06 20:00',
|
||||
])
|
||||
assert.equal(found[0].at.toISOString(), '2026-10-23T18:00:00.000Z')
|
||||
assert.equal(found[1].at.toISOString(), '2026-10-30T19:00:00.000Z')
|
||||
})
|
||||
|
||||
// ── The two DST rules, as decided ──────────────────────────────────────────
|
||||
|
||||
test('a local time the spring gap swallows moves FORWARD to the first one that exists', () => {
|
||||
// 2026-03-29 in Berlin: 02:00 becomes 03:00 and 02:30 never happens. The
|
||||
// decision is the first valid instant — 03:00 — rather than "shift by the gap"
|
||||
// (03:30): the event happens as close to the authored time as the calendar
|
||||
// allows.
|
||||
const resolved = r.resolveWall('Europe/Berlin', 2026, 3, 29, 2, 30)
|
||||
assert.equal(resolved.adjusted, 'gap')
|
||||
assert.equal(wall('Europe/Berlin', resolved.at), '2026-03-29 03:00')
|
||||
assert.equal(resolved.at.toISOString(), '2026-03-29T01:00:00.000Z')
|
||||
assert.equal(resolved.shiftMinutes, 30)
|
||||
})
|
||||
|
||||
test('a local time that happens twice takes the FIRST of them', () => {
|
||||
// 2026-10-25 in Berlin: 02:30 comes round at 00:30Z (+2, still CEST) and again
|
||||
// at 01:30Z (+1, now CET). The first is the answer, and the second must not be
|
||||
// — an event that fired at the later one would be an hour late by the clock
|
||||
// the author wrote it against.
|
||||
const resolved = r.resolveWall('Europe/Berlin', 2026, 10, 25, 2, 30)
|
||||
assert.equal(resolved.adjusted, 'ambiguous')
|
||||
assert.equal(resolved.at.toISOString(), '2026-10-25T00:30:00.000Z')
|
||||
assert.equal(wall('Europe/Berlin', resolved.at), '2026-10-25 02:30')
|
||||
|
||||
// Both instants really do read 02:30 — the fixture is only meaningful if the
|
||||
// ambiguity is real.
|
||||
const both = r.instantsForWall('Europe/Berlin', Date.UTC(2026, 9, 25, 2, 30))
|
||||
assert.equal(both.length, 2)
|
||||
assert.equal(both[0], Date.UTC(2026, 9, 25, 0, 30))
|
||||
assert.equal(both[1], Date.UTC(2026, 9, 25, 1, 30))
|
||||
})
|
||||
|
||||
test('a weekly event in the gap still happens that week — it is never dropped', () => {
|
||||
// The rule that makes the gap decision worth having. A Sunday 02:30 event in
|
||||
// Berlin happens on 29 March like every other Sunday; it simply happens at
|
||||
// 03:00.
|
||||
const schedule = { kind: 'weekly', days: ['sunday'], time: '02:30' }
|
||||
const found = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 6))
|
||||
assert.deepEqual(walls(found, 'Europe/Berlin'), [
|
||||
'2026-03-22 02:30',
|
||||
'2026-03-29 03:00',
|
||||
'2026-04-05 02:30',
|
||||
])
|
||||
assert.equal(found[1].adjusted, 'gap')
|
||||
assert.equal(found[0].adjusted, null)
|
||||
})
|
||||
|
||||
test('a thirty-minute DST shift resolves too — the gap is not always an hour', () => {
|
||||
// Lord Howe Island shifts by 30 minutes (+10:30 to +11:00). 2026-10-04 has no
|
||||
// 02:15 local. An implementation that assumed a whole-hour gap gets this wrong.
|
||||
const resolved = r.resolveWall('Australia/Lord_Howe', 2026, 10, 4, 2, 15)
|
||||
assert.equal(resolved.adjusted, 'gap')
|
||||
assert.equal(wall('Australia/Lord_Howe', resolved.at), '2026-10-04 02:30')
|
||||
})
|
||||
|
||||
test('a zone with no DST at all is left completely alone', () => {
|
||||
// Asia/Kolkata is +05:30 all year, and the half hour is the point: an
|
||||
// implementation carrying whole-hour offsets around would be 30 minutes out
|
||||
// here, every day, without any transition to blame.
|
||||
const schedule = { kind: 'weekly', days: ['friday'], time: '19:30' }
|
||||
const found = r.occurrencesBetween(schedule, 'Asia/Kolkata', Date.UTC(2026, 2, 20), Date.UTC(2026, 3, 11))
|
||||
assert.equal(found.length, 4)
|
||||
for (const o of found) {
|
||||
assert.equal(o.adjusted, null)
|
||||
assert.equal(wall('Asia/Kolkata', o.at).slice(11), '19:30')
|
||||
assert.equal(o.at.toISOString().slice(11, 16), '14:00')
|
||||
}
|
||||
})
|
||||
|
||||
// ── The shapes ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('`once` produces its single occurrence, and only inside the window', () => {
|
||||
const schedule = { kind: 'once', at: '2026-10-31T20:00' }
|
||||
const inside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 9, 1), Date.UTC(2026, 10, 1))
|
||||
assert.equal(inside.length, 1)
|
||||
assert.equal(wall('Europe/Berlin', inside[0].at), '2026-10-31 20:00')
|
||||
|
||||
const outside = r.occurrencesBetween(schedule, 'Europe/Berlin', Date.UTC(2026, 10, 1), Date.UTC(2026, 11, 1))
|
||||
assert.deepEqual(outside, [])
|
||||
})
|
||||
|
||||
test('`weekly` honours every named day, in week order', () => {
|
||||
const schedule = { kind: 'weekly', days: ['saturday', 'wednesday'], time: '18:00' }
|
||||
const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 15))
|
||||
assert.deepEqual(walls(found, 'UTC'), [
|
||||
'2026-06-03 18:00',
|
||||
'2026-06-06 18:00',
|
||||
'2026-06-10 18:00',
|
||||
'2026-06-13 18:00',
|
||||
])
|
||||
})
|
||||
|
||||
test('`monthly` with nth: -1 is the LAST weekday, which is not always the fourth', () => {
|
||||
// The whole reason -1 exists. May 2026 has five Fridays and July 2026 has five;
|
||||
// in those months "last" and "fourth" are different days, and a fishing contest
|
||||
// on the last Friday is exactly that shape.
|
||||
const last = r.occurrencesBetween(
|
||||
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:00' },
|
||||
'UTC',
|
||||
Date.UTC(2026, 4, 1),
|
||||
Date.UTC(2026, 8, 1),
|
||||
)
|
||||
const fourth = r.occurrencesBetween(
|
||||
{ kind: 'monthly', nth: 4, weekday: 'friday', time: '19:00' },
|
||||
'UTC',
|
||||
Date.UTC(2026, 4, 1),
|
||||
Date.UTC(2026, 8, 1),
|
||||
)
|
||||
// August 2026 has four Fridays, so "last" and "fourth" agree there and
|
||||
// disagree in May and July. That the two lists share a member is the point:
|
||||
// -1 is not a synonym for 4, and it is not a synonym for "different" either.
|
||||
assert.deepEqual(walls(last, 'UTC'), [
|
||||
'2026-05-29 19:00',
|
||||
'2026-06-26 19:00',
|
||||
'2026-07-31 19:00',
|
||||
'2026-08-28 19:00',
|
||||
])
|
||||
assert.deepEqual(walls(fourth, 'UTC'), [
|
||||
'2026-05-22 19:00',
|
||||
'2026-06-26 19:00',
|
||||
'2026-07-24 19:00',
|
||||
'2026-08-28 19:00',
|
||||
])
|
||||
assert.notDeepEqual(walls(last, 'UTC'), walls(fourth, 'UTC'))
|
||||
})
|
||||
|
||||
test('every month has a first through fourth of every weekday', () => {
|
||||
// The claim the closed set rests on: because there is no `nth: 5`, there is no
|
||||
// absent-occurrence case to define. Checked across three years rather than
|
||||
// asserted in a comment.
|
||||
for (let year = 2026; year <= 2028; year += 1) {
|
||||
for (let month = 1; month <= 12; month += 1) {
|
||||
for (let weekday = 0; weekday <= 6; weekday += 1) {
|
||||
for (const nth of [1, 2, 3, 4, -1]) {
|
||||
const day = r.nthWeekdayDay(year, month, weekday, nth)
|
||||
assert.ok(day, `${year}-${month} weekday ${weekday} nth ${nth} should exist`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('`manual` is not a recurrence and expands to nothing', () => {
|
||||
assert.deepEqual(r.occurrencesBetween({ kind: 'manual' }, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1)), [])
|
||||
})
|
||||
|
||||
// ── Bounds and refusals ────────────────────────────────────────────────────
|
||||
|
||||
test('an inverted or empty window answers with nothing rather than throwing', () => {
|
||||
const schedule = { kind: 'weekly', days: ['friday'], time: '20:00' }
|
||||
assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 4, 1)), [])
|
||||
assert.deepEqual(r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 5, 1), Date.UTC(2026, 5, 1)), [])
|
||||
})
|
||||
|
||||
test('a malformed schedule expands to nothing rather than to a wrong instant', () => {
|
||||
// These shapes cannot come through `spec.js`, but they can come from a row
|
||||
// written directly into the database — and the runner must not turn one into a
|
||||
// world change at an invented time.
|
||||
assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: [], time: '20:00' }, 'UTC', 0, 1e12), [])
|
||||
assert.deepEqual(r.occurrencesBetween({ kind: 'weekly', days: ['friday'], time: '25:00' }, 'UTC', 0, 1e12), [])
|
||||
assert.deepEqual(r.occurrencesBetween({ kind: 'monthly', nth: 9, weekday: 'friday', time: '19:00' }, 'UTC', 0, 1e12), [])
|
||||
assert.deepEqual(r.occurrencesBetween({ kind: 'once', at: 'tomorrow' }, 'UTC', 0, 1e12), [])
|
||||
assert.deepEqual(r.occurrencesBetween(null, 'UTC', 0, 1e12), [])
|
||||
})
|
||||
|
||||
test('the expansion is bounded, so a wide window cannot become an outage', () => {
|
||||
const schedule = {
|
||||
kind: 'weekly',
|
||||
days: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'],
|
||||
time: '12:00',
|
||||
}
|
||||
const found = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2020, 0, 1), Date.UTC(2030, 0, 1))
|
||||
assert.equal(found.length, r.MAX_OCCURRENCES)
|
||||
|
||||
const smaller = r.occurrencesBetween(schedule, 'UTC', Date.UTC(2026, 0, 1), Date.UTC(2027, 0, 1), { limit: 10 })
|
||||
assert.equal(smaller.length, 10)
|
||||
})
|
||||
|
||||
test('nextOccurrence looks forward and finds nothing when there is nothing', () => {
|
||||
const weekly = r.nextOccurrence({ kind: 'weekly', days: ['friday'], time: '20:00' }, 'UTC', Date.UTC(2026, 5, 1))
|
||||
assert.equal(wall('UTC', weekly.at), '2026-06-05 20:00')
|
||||
|
||||
// A `once` already in the past has no next occurrence, which is what stops a
|
||||
// one-off event being re-materialised for ever.
|
||||
assert.equal(r.nextOccurrence({ kind: 'once', at: '2020-01-01T12:00' }, 'UTC', Date.UTC(2026, 5, 1)), null)
|
||||
assert.equal(r.nextOccurrence({ kind: 'manual' }, 'UTC', Date.UTC(2026, 5, 1)), null)
|
||||
})
|
||||
|
||||
test('describe says the schedule back in words, in the event own zone', () => {
|
||||
assert.equal(
|
||||
r.describe({ kind: 'weekly', days: ['friday', 'saturday'], time: '20:00' }, 'Europe/Berlin'),
|
||||
'Every Friday and Saturday at 20:00 (Europe/Berlin)',
|
||||
)
|
||||
assert.equal(
|
||||
r.describe({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }, 'Asia/Kolkata'),
|
||||
'The last Friday of every month at 19:30 (Asia/Kolkata)',
|
||||
)
|
||||
assert.equal(r.describe({ kind: 'manual' }), 'Started by hand')
|
||||
})
|
||||
@@ -35,6 +35,7 @@ const runsDb = require('../src/model/events/eventRuns.db')
|
||||
const stepsDb = require('../src/model/events/eventRunSteps.db')
|
||||
const logDb = require('../src/model/events/eventRunLog.db')
|
||||
const versionsDb = require('../src/model/events/eventVersions.db')
|
||||
const definitionsDb = require('../src/model/events/eventDefinitions.db')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -47,7 +48,7 @@ const later = (ms) => new Date(T0.getTime() + ms)
|
||||
let store
|
||||
const originals = {}
|
||||
|
||||
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb]]) {
|
||||
for (const [name, mod] of [['runsDb', runsDb], ['stepsDb', stepsDb], ['logDb', logDb], ['versionsDb', versionsDb], ['definitionsDb', definitionsDb]]) {
|
||||
originals[name] = { mod, fns: { ...mod } }
|
||||
}
|
||||
|
||||
@@ -68,6 +69,14 @@ function installStubs() {
|
||||
nextStepId: 1,
|
||||
}
|
||||
|
||||
// Phase 4 put a schedule-expansion leg in front of the tick. This file is
|
||||
// about what the runner does with runs that ALREADY exist, so it has nothing
|
||||
// to expand — but the leg is a real query, and left unstubbed every `tick()`
|
||||
// here would reach for the dead-port pool and wait on it. Answering with an
|
||||
// empty list is what keeps this file measuring the runner rather than a
|
||||
// connection timeout.
|
||||
Object.assign(definitionsDb, { findSchedulable: async () => [] })
|
||||
|
||||
// Snapshots, not live references. A SQL SELECT hands back a copy, and the
|
||||
// runner reads `step.attempts` as the value BEFORE its own claim incremented
|
||||
// it — returning references here would make the retry budget off by one in the
|
||||
|
||||
@@ -38,6 +38,22 @@
|
||||
// unsettled seq instead, which is a different step whenever a phase carried
|
||||
// on past an `on_failure: skip` failure.
|
||||
//
|
||||
// **Phase 4 added two reads**, and a read earns a place here when a stub cannot
|
||||
// tell it is wrong:
|
||||
//
|
||||
// * **`findSchedulable`** - the query the runner runs on EVERY tick to decide
|
||||
// what has a recurrence to expand. It joins a definition to its published
|
||||
// version and left-joins the series, and every stub of it in
|
||||
// `eventSchedule.test.js` is a hand-written object rather than that join. A
|
||||
// syntax error or a wrong join direction here is a runner that materialises
|
||||
// nothing, silently, for ever.
|
||||
// * **`repinScheduled`** - the UPDATE publish runs over already-materialised
|
||||
// occurrences. Its guard is the whole statement, and the two rows it must
|
||||
// NOT touch are a run that has started and a run that is already terminal.
|
||||
// * **`listInWindow`** - the calendar's real half, with a correlated subquery
|
||||
// for `waiting_steps` and a LEFT JOIN that must not drop a definition with no
|
||||
// series.
|
||||
//
|
||||
// Plus the two unique indexes that are load-bearing rather than tidy:
|
||||
// `uq_evrun_occurrence` (which, not the claim, is what stops two runs of one
|
||||
// occurrence existing) and `uq_evstep_slot` (which is what makes re-materialising
|
||||
@@ -61,10 +77,29 @@ const mariadb = require('mariadb')
|
||||
// Trimmed to the columns these statements read or write. The ENUMs are verbatim,
|
||||
// because "is `missed` a legal value" is one of the things being proved.
|
||||
const SCHEMA = `
|
||||
CREATE TABLE event_series (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
slug VARCHAR(160) NOT NULL,
|
||||
ordering INT NOT NULL DEFAULT 0
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_definitions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL DEFAULT 'x',
|
||||
slug VARCHAR(200) NOT NULL DEFAULT 'x',
|
||||
state ENUM('draft','ready','archived') NOT NULL DEFAULT 'draft',
|
||||
current_version_id INT NULL,
|
||||
series_id INT NULL,
|
||||
concurrency_key VARCHAR(190) NULL,
|
||||
timezone VARCHAR(64) NOT NULL DEFAULT 'UTC',
|
||||
grace_seconds INT NOT NULL DEFAULT 900
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_versions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
definition_id INT NOT NULL,
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
spec JSON NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
CREATE TABLE event_runs (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
definition_id INT NOT NULL,
|
||||
@@ -207,6 +242,41 @@ SELECT r.id FROM event_runs r
|
||||
WHERE r.status = 'scheduled'
|
||||
AND r.scheduled_for + INTERVAL d.grace_seconds SECOND < ?`
|
||||
|
||||
// Verbatim `eventDefinitions.db#findSchedulable`.
|
||||
const FIND_SCHEDULABLE = `
|
||||
SELECT d.id, d.title, d.slug, d.timezone, d.grace_seconds, d.concurrency_key,
|
||||
d.current_version_id, d.series_id, v.spec AS version_spec,
|
||||
s.name AS series_name, s.slug AS series_slug
|
||||
FROM event_definitions d
|
||||
JOIN event_versions v ON v.id = d.current_version_id
|
||||
LEFT JOIN event_series s ON s.id = d.series_id
|
||||
WHERE d.state = 'ready'
|
||||
ORDER BY d.id`
|
||||
|
||||
// Verbatim `eventRuns.db#listInWindow`, with no optional filter applied.
|
||||
const LIST_IN_WINDOW = `
|
||||
SELECT r.*, d.title AS definition_title, d.slug AS definition_slug,
|
||||
d.series_id AS series_id, se.name AS series_name, se.slug AS series_slug,
|
||||
v.version AS version_number,
|
||||
(SELECT COUNT(*) FROM event_run_steps s
|
||||
WHERE s.run_id = r.id AND s.status = 'running' AND s.claim_expires_at IS NULL) AS waiting_steps
|
||||
FROM event_runs r
|
||||
JOIN event_definitions d ON d.id = r.definition_id
|
||||
JOIN event_versions v ON v.id = r.version_id
|
||||
LEFT JOIN event_series se ON se.id = d.series_id
|
||||
WHERE r.scheduled_for >= ? AND r.scheduled_for < ?
|
||||
ORDER BY r.scheduled_for, r.id
|
||||
LIMIT 500`
|
||||
|
||||
// Verbatim `eventRuns.db#repinScheduled`.
|
||||
const REPIN_SCHEDULED = `
|
||||
UPDATE event_runs
|
||||
SET version_id = ?
|
||||
WHERE definition_id = ?
|
||||
AND status = 'scheduled'
|
||||
AND started_at IS NULL
|
||||
AND version_id <> ?`
|
||||
|
||||
const DB = `rg_events_test_${process.pid}`
|
||||
let pool = null
|
||||
let available = false
|
||||
@@ -274,6 +344,8 @@ beforeEach(async () => {
|
||||
await pool.query('DELETE FROM event_run_steps')
|
||||
await pool.query('DELETE FROM event_runs')
|
||||
await pool.query('DELETE FROM event_definitions')
|
||||
await pool.query('DELETE FROM event_versions')
|
||||
await pool.query('DELETE FROM event_series')
|
||||
})
|
||||
|
||||
async function seedRun(over = {}) {
|
||||
@@ -659,3 +731,163 @@ test('a guarded transition refuses a run that was cancelled underneath it', asyn
|
||||
assert.equal(rows(await pool.query(TRANSITION, ['running', 'two', runId, 'running'])), 0)
|
||||
assert.equal((await runById(runId)).status, 'cancelled')
|
||||
})
|
||||
|
||||
|
||||
// ── Phase 4: the two reads ────────────────────────────────────────
|
||||
|
||||
/** A definition with a published version, and optionally a series. */
|
||||
async function seedDefinition({ state = 'ready', spec = { schedule: { kind: 'manual' } }, series = null } = {}) {
|
||||
let seriesId = null
|
||||
if (series) {
|
||||
const s = await pool.query('INSERT INTO event_series (name, slug) VALUES (?, ?)', [series, series])
|
||||
seriesId = s.insertId
|
||||
}
|
||||
const d = await pool.query(
|
||||
'INSERT INTO event_definitions (state, series_id, timezone) VALUES (?, ?, ?)',
|
||||
[state, seriesId, 'Europe/Berlin'],
|
||||
)
|
||||
const v = await pool.query(
|
||||
'INSERT INTO event_versions (definition_id, version, spec) VALUES (?, 1, ?)',
|
||||
[d.insertId, JSON.stringify(spec)],
|
||||
)
|
||||
await pool.query('UPDATE event_definitions SET current_version_id = ? WHERE id = ?', [
|
||||
v.insertId,
|
||||
d.insertId,
|
||||
])
|
||||
return { definitionId: d.insertId, versionId: v.insertId, seriesId }
|
||||
}
|
||||
|
||||
test('findSchedulable returns ready definitions with their PUBLISHED spec, series or not', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const withSeries = await seedDefinition({
|
||||
spec: { schedule: { kind: 'weekly', days: ['friday'], time: '20:00' } },
|
||||
series: 'royal-spy',
|
||||
})
|
||||
const withoutSeries = await seedDefinition({ spec: { schedule: { kind: 'manual' } } })
|
||||
|
||||
const found = await pool.query(FIND_SCHEDULABLE)
|
||||
const ids = found.map((r) => r.id).sort((a, b) => a - b)
|
||||
assert.deepEqual(ids, [withSeries.definitionId, withoutSeries.definitionId].sort((a, b) => a - b))
|
||||
|
||||
// The LEFT JOIN must not drop the definition that belongs to no arc — an
|
||||
// inner join here would make every event outside a series unschedulable, and
|
||||
// most events are outside one.
|
||||
const plain = found.find((r) => r.id === withoutSeries.definitionId)
|
||||
assert.equal(plain.series_name, null)
|
||||
|
||||
const arced = found.find((r) => r.id === withSeries.definitionId)
|
||||
assert.equal(arced.series_name, 'royal-spy')
|
||||
assert.equal(arced.timezone, 'Europe/Berlin')
|
||||
|
||||
// The spec really came back, and really came back parseable.
|
||||
const spec = typeof arced.version_spec === 'string' ? JSON.parse(arced.version_spec) : arced.version_spec
|
||||
assert.equal(spec.schedule.kind, 'weekly')
|
||||
})
|
||||
|
||||
test('findSchedulable skips a draft, an archived one, and one with no published version', async (t) => {
|
||||
if (needDb(t)) return
|
||||
await seedDefinition({ state: 'draft' })
|
||||
await seedDefinition({ state: 'archived' })
|
||||
// `ready` with a dangling version pointer: the JOIN is what must drop it, and
|
||||
// a definition whose version row went missing must not become a runner crash.
|
||||
const orphan = await seedDefinition({ state: 'ready' })
|
||||
await pool.query('DELETE FROM event_versions WHERE id = ?', [orphan.versionId])
|
||||
|
||||
assert.equal((await pool.query(FIND_SCHEDULABLE)).length, 0)
|
||||
})
|
||||
|
||||
test('listInWindow is half-open on the window, and counts only PARKED steps as waiting', async (t) => {
|
||||
if (needDb(t)) return
|
||||
const def = await seedDefinition()
|
||||
const at = async (when) => {
|
||||
const r = await pool.query(
|
||||
'INSERT INTO event_runs (definition_id, version_id, scope, scheduled_for) VALUES (?, ?, ?, ?)',
|
||||
[def.definitionId, def.versionId, '', when],
|
||||
)
|
||||
return r.insertId
|
||||
}
|
||||
const before = await at(new Date('2026-09-01T00:00:00Z'))
|
||||
const onFrom = await at(new Date('2026-09-02T00:00:00Z'))
|
||||
const inside = await at(new Date('2026-09-05T00:00:00Z'))
|
||||
const onTo = await at(new Date('2026-09-09T00:00:00Z'))
|
||||
|
||||
// `>= from AND < to` — the instant ON the upper bound belongs to the NEXT
|
||||
// window. A closed interval would draw the last day of one month and the first
|
||||
// of the next as the same occurrence twice.
|
||||
const found = await pool.query(LIST_IN_WINDOW, [
|
||||
new Date('2026-09-02T00:00:00Z'),
|
||||
new Date('2026-09-09T00:00:00Z'),
|
||||
])
|
||||
assert.deepEqual(found.map((r) => r.id), [onFrom, inside])
|
||||
assert.ok(!found.some((r) => r.id === before || r.id === onTo))
|
||||
|
||||
// A parked step is `running` with a NULL lease; a leased one is the runner
|
||||
// mid-dispatch and is not waiting on anybody.
|
||||
await seedStep(inside, { status: 'running', claimExpiresAt: null, key: 'a'.repeat(40) })
|
||||
await seedStep(inside, {
|
||||
seq: 1,
|
||||
status: 'running',
|
||||
claimedBy: 'host',
|
||||
claimExpiresAt: later(60_000),
|
||||
key: 'b'.repeat(40),
|
||||
})
|
||||
const again = await pool.query(LIST_IN_WINDOW, [
|
||||
new Date('2026-09-02T00:00:00Z'),
|
||||
new Date('2026-09-09T00:00:00Z'),
|
||||
])
|
||||
assert.equal(Number(again.find((r) => r.id === inside).waiting_steps), 1)
|
||||
})
|
||||
|
||||
|
||||
test('repinScheduled moves the occurrences that have not begun, and only those', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// The case: an editor fixes a typo on a weekly event on Wednesday. Two Fridays
|
||||
// are already materialised on v3, last Friday's run is finished, and one is in
|
||||
// flight right now.
|
||||
const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
|
||||
const mk = async (status, versionId, startedAt, when) =>
|
||||
(
|
||||
await pool.query(
|
||||
`INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[def.insertId, versionId, `s${when}`, status, T0, startedAt],
|
||||
)
|
||||
).insertId
|
||||
|
||||
const ahead1 = await mk('scheduled', 3, null, 1)
|
||||
const ahead2 = await mk('scheduled', 3, null, 2)
|
||||
const running = await mk('running', 3, T0, 3)
|
||||
const done = await mk('completed', 3, T0, 4)
|
||||
// Already on the new version: excluded by `version_id <> ?`, so a second
|
||||
// publish of an unchanged definition is not a fleet of pointless writes.
|
||||
const already = await mk('scheduled', 4, null, 5)
|
||||
|
||||
const moved = rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4]))
|
||||
assert.equal(moved, 2)
|
||||
|
||||
const versionOf = async (id) =>
|
||||
Number((await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [id]))[0].version_id)
|
||||
assert.equal(await versionOf(ahead1), 4)
|
||||
assert.equal(await versionOf(ahead2), 4)
|
||||
// A run that has begun keeps the version it pinned, for ever: that pin is what
|
||||
// makes it explicable afterwards.
|
||||
assert.equal(await versionOf(running), 3)
|
||||
assert.equal(await versionOf(done), 3)
|
||||
assert.equal(await versionOf(already), 4)
|
||||
})
|
||||
|
||||
test('a scheduled run whose started_at is somehow set is left alone', async (t) => {
|
||||
if (needDb(t)) return
|
||||
// Belt and braces on the guard: `status = 'scheduled'` and `started_at IS NULL`
|
||||
// are two conditions rather than one because a row that has both is the only
|
||||
// row that is provably untouched.
|
||||
const def = await pool.query('INSERT INTO event_definitions (grace_seconds) VALUES (900)')
|
||||
const r = await pool.query(
|
||||
`INSERT INTO event_runs (definition_id, version_id, scope, status, scheduled_for, started_at)
|
||||
VALUES (?, 3, '', 'scheduled', ?, ?)`,
|
||||
[def.insertId, T0, T0],
|
||||
)
|
||||
assert.equal(rows(await pool.query(REPIN_SCHEDULED, [4, def.insertId, 4])), 0)
|
||||
const after = (await pool.query('SELECT version_id FROM event_runs WHERE id = ?', [r.insertId]))[0]
|
||||
assert.equal(Number(after.version_id), 3)
|
||||
})
|
||||
|
||||
391
server/test/eventSchedule.test.js
Normal file
391
server/test/eventSchedule.test.js
Normal file
@@ -0,0 +1,391 @@
|
||||
// ── 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')
|
||||
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],
|
||||
]) {
|
||||
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,
|
||||
})
|
||||
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 } })
|
||||
}
|
||||
|
||||
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')
|
||||
})
|
||||
113
server/test/eventSeries.test.js
Normal file
113
server/test/eventSeries.test.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// ── Event series, the arc (EVENTS.md §D/§I, Phase 4) ───────────────────────
|
||||
//
|
||||
// One small table, and the reason it is worth testing at all is the two rules
|
||||
// that are not obvious from its four columns:
|
||||
//
|
||||
// • the slug is derived once and FROZEN. The public arc page lives at it, so
|
||||
// a rename that moved it would break every link — including the ones inside
|
||||
// the Discord posts this feature will eventually write.
|
||||
// • the delete is a real delete, and it is the only one in this feature. A
|
||||
// definition is archived instead, because a run pins its version and history
|
||||
// that cannot be explained defeats the audit. A series pins nothing: it is a
|
||||
// label, `series_id` is ON DELETE SET NULL, and the count of what it detached
|
||||
// is what an operator needs to be told.
|
||||
|
||||
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 seriesDb = require('../src/model/events/eventSeries.db')
|
||||
const series = require('../src/model/events/eventSeries.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
|
||||
let store
|
||||
const original = { ...seriesDb }
|
||||
|
||||
beforeEach(() => {
|
||||
store = { rows: new Map(), nextId: 1 }
|
||||
Object.assign(seriesDb, {
|
||||
list: async () => [...store.rows.values()].sort((a, b) => a.ordering - b.ordering || a.id - b.id),
|
||||
getById: async (id) => store.rows.get(id) || null,
|
||||
exists: async (id) => store.rows.has(id),
|
||||
insert: async (s) => {
|
||||
const id = store.nextId++
|
||||
store.rows.set(id, { ...s, id, definition_count: 0 })
|
||||
return id
|
||||
},
|
||||
update: async (id, s) => {
|
||||
const existing = store.rows.get(id)
|
||||
store.rows.set(id, { ...existing, ...s })
|
||||
return 1
|
||||
},
|
||||
remove: async (id) => {
|
||||
store.rows.delete(id)
|
||||
return 1
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => Object.assign(seriesDb, original))
|
||||
|
||||
test('a series is created with a slug derived from its name', async () => {
|
||||
const result = await series.create({ name: 'Royal Spy Mission', ordering: 2 }, 7)
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.status, 201)
|
||||
assert.equal(result.series.slug, 'royal-spy-mission')
|
||||
assert.equal(result.series.ordering, 2)
|
||||
assert.equal(result.series.created_by, 7)
|
||||
})
|
||||
|
||||
test('two series with the same name get distinct slugs', async () => {
|
||||
// `slug` is UNIQUE in the schema, so without this the second create is a 1452
|
||||
// reaching a controller as a 500.
|
||||
const first = await series.create({ name: 'Winter Arc' })
|
||||
const second = await series.create({ name: 'Winter Arc' })
|
||||
assert.equal(first.series.slug, 'winter-arc')
|
||||
assert.equal(second.series.slug, 'winter-arc-2')
|
||||
})
|
||||
|
||||
test('renaming a series does NOT move its slug', async () => {
|
||||
// The rule with teeth. The arc page lives at the slug, and a rename is the
|
||||
// ordinary act of an editor tidying up wording months later.
|
||||
const created = await series.create({ name: 'Royal Spy Mission' })
|
||||
const updated = await series.update(created.series.id, { name: 'The Royal Spy Missions' })
|
||||
assert.equal(updated.ok, true)
|
||||
assert.equal(updated.series.name, 'The Royal Spy Missions')
|
||||
assert.equal(updated.series.slug, 'royal-spy-mission')
|
||||
})
|
||||
|
||||
test('a nameless series is refused, and so is a nonsense ordering', async () => {
|
||||
assert.deepEqual((await series.create({ name: ' ' })).errors, ['name is required'])
|
||||
const bad = await series.create({ name: 'Fine', ordering: -3 })
|
||||
assert.equal(bad.ok, false)
|
||||
assert.match(bad.errors.join(' '), /ordering must be an integer/)
|
||||
})
|
||||
|
||||
test('deleting a series answers with how many definitions it detached', async () => {
|
||||
// The whole consequence of this delete is about the rows it does NOT delete,
|
||||
// so the count is the answer rather than a detail.
|
||||
const created = await series.create({ name: 'Winter Arc' })
|
||||
store.rows.get(created.series.id).definition_count = 3
|
||||
|
||||
const removed = await series.remove(created.series.id)
|
||||
assert.equal(removed.ok, true)
|
||||
assert.equal(removed.detached, 3)
|
||||
assert.equal(await seriesDb.getById(created.series.id), null)
|
||||
})
|
||||
|
||||
test('acting on a series that is not there is a 404, never a 500', async () => {
|
||||
assert.equal((await series.update(999, { name: 'x' })).status, 404)
|
||||
assert.equal((await series.remove(999)).status, 404)
|
||||
})
|
||||
|
||||
test('ordering places a series among the others, and defaults to zero', async () => {
|
||||
await series.create({ name: 'Third', ordering: 30 })
|
||||
await series.create({ name: 'First', ordering: 10 })
|
||||
await series.create({ name: 'Unordered' })
|
||||
const listed = await series.list()
|
||||
assert.deepEqual(listed.map((s) => s.name), ['Unordered', 'First', 'Third'])
|
||||
})
|
||||
@@ -192,13 +192,104 @@ test('a key a later phase owns is refused, not silently preserved', () => {
|
||||
assert.match(phase.errors.join('\n'), /unknown key\(s\) advance .*Phase 5/)
|
||||
})
|
||||
|
||||
test('only the manual schedule exists in this phase', () => {
|
||||
const weekly = spec.validate({
|
||||
schedule: { kind: 'weekly', days: ['fri'], time: '20:00' },
|
||||
phases: [{ key: 'main', label: 'Main', steps: [] }],
|
||||
// ── The schedule shapes (Phase 4) ────────────────────────────────────
|
||||
//
|
||||
// Every check here is on SHAPE. What the shapes MEAN — the zone arithmetic, the
|
||||
// DST rules — is `eventRecurrence.test.js`. The split is deliberate: this file
|
||||
// answers "may this be saved", that one answers "when does it happen", and the
|
||||
// second question is only worth asking of something that passed the first.
|
||||
|
||||
const withSchedule = (schedule) =>
|
||||
spec.validate({ schedule, phases: [{ key: 'main', label: 'Main', steps: [] }] })
|
||||
|
||||
test('the four closed shapes are accepted and normalised', () => {
|
||||
assert.deepEqual(withSchedule({ kind: 'manual' }).spec.schedule, { kind: 'manual' })
|
||||
assert.deepEqual(withSchedule({ kind: 'once', at: '2026-10-31T20:00' }).spec.schedule, {
|
||||
kind: 'once',
|
||||
at: '2026-10-31T20:00',
|
||||
})
|
||||
assert.equal(weekly.ok, false)
|
||||
assert.match(weekly.errors.join('\n'), /recurrence arrives in Phase 4/)
|
||||
assert.deepEqual(withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00' }).spec.schedule, {
|
||||
kind: 'weekly',
|
||||
days: ['friday'],
|
||||
time: '20:00',
|
||||
})
|
||||
assert.deepEqual(
|
||||
withSchedule({ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' }).spec.schedule,
|
||||
{ kind: 'monthly', nth: -1, weekday: 'friday', time: '19:30' },
|
||||
)
|
||||
})
|
||||
|
||||
test('the validator accepts its own output for every shape', () => {
|
||||
// Phase 1's rule, and it is a rule about the SECOND save of any definition
|
||||
// rather than about a round trip for its own sake: `validate` normalises, and
|
||||
// publish re-validates what a save wrote. A normaliser that refuses what it
|
||||
// emits makes a published definition uneditable.
|
||||
for (const schedule of [
|
||||
{ kind: 'manual' },
|
||||
{ kind: 'once', at: '2026-10-31T20:00' },
|
||||
{ kind: 'weekly', days: ['friday', 'monday'], time: '20:00' },
|
||||
{ kind: 'monthly', nth: 4, weekday: 'friday', time: '19:30' },
|
||||
]) {
|
||||
const first = withSchedule(schedule)
|
||||
assert.equal(first.ok, true, JSON.stringify(schedule))
|
||||
const second = withSchedule(first.spec.schedule)
|
||||
assert.equal(second.ok, true, JSON.stringify(first.spec.schedule))
|
||||
assert.deepEqual(second.spec.schedule, first.spec.schedule)
|
||||
}
|
||||
})
|
||||
|
||||
test('weekly days are normalised into week order and deduped', () => {
|
||||
// Not tidiness. The spec is snapshotted into a version and diffed, so two
|
||||
// orderings of the same schedule would show as an edit nobody made.
|
||||
const result = withSchedule({ kind: 'weekly', days: ['Friday', 'monday', 'FRIDAY'], time: '20:00' })
|
||||
assert.deepEqual(result.spec.schedule.days, ['monday', 'friday'])
|
||||
})
|
||||
|
||||
test('a shape may not carry another shape keys', () => {
|
||||
const result = withSchedule({ kind: 'weekly', days: ['friday'], time: '20:00', at: '2026-01-01T00:00' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors.join('\n'), /unknown key\(s\) at for kind "weekly"/)
|
||||
})
|
||||
|
||||
test('an unknown kind is refused, and the message names the four', () => {
|
||||
const result = withSchedule({ kind: 'daily', time: '20:00' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors.join('\n'), /manual, once, weekly, monthly/)
|
||||
})
|
||||
|
||||
test('a date that is not a real day is refused', () => {
|
||||
// The regex admits 2026-02-30 quite happily. A schedule that parses and then
|
||||
// resolves to some other day is worse than one that is refused.
|
||||
const result = withSchedule({ kind: 'once', at: '2026-02-30T20:00' })
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.errors.join('\n'), /is not a real date/)
|
||||
})
|
||||
|
||||
test('every malformed schedule field is named, not merely rejected', () => {
|
||||
assert.match(withSchedule({ kind: 'once', at: 'soon' }).errors.join('\n'), /YYYY-MM-DDTHH:MM/)
|
||||
assert.match(withSchedule({ kind: 'weekly', days: [], time: '20:00' }).errors.join('\n'), /non-empty array/)
|
||||
assert.match(withSchedule({ kind: 'weekly', days: ['froday'], time: '20:00' }).errors.join('\n'), /unknown weekday/)
|
||||
assert.match(withSchedule({ kind: 'weekly', days: ['friday'], time: '25:00' }).errors.join('\n'), /24-hour time/)
|
||||
assert.match(
|
||||
withSchedule({ kind: 'monthly', nth: 5, weekday: 'friday', time: '19:30' }).errors.join('\n'),
|
||||
/1, 2, 3, 4 or -1/,
|
||||
)
|
||||
assert.match(
|
||||
withSchedule({ kind: 'monthly', nth: 1, weekday: 'froday', time: '19:30' }).errors.join('\n'),
|
||||
/expected one of sunday/,
|
||||
)
|
||||
})
|
||||
|
||||
test('a refused schedule leaves a manual one behind rather than half a recurrence', () => {
|
||||
// `validate` collects every error and carries on, so the spec object exists
|
||||
// even when the answer is no. A caller reading `schedule.days` of it must not
|
||||
// find a partially built weekly.
|
||||
const result = spec.validate({
|
||||
schedule: { kind: 'weekly', days: ['froday'], time: '20:00' },
|
||||
phases: [{ key: 'BAD KEY', label: '', steps: [] }],
|
||||
})
|
||||
assert.equal(result.ok, false)
|
||||
assert.ok(result.errors.length > 1)
|
||||
})
|
||||
|
||||
test('every problem is reported, not just the first', () => {
|
||||
|
||||
@@ -160,6 +160,21 @@ function installStubs() {
|
||||
.filter((r) => (!definitionId || r.definition_id === definitionId) && (!status || r.status === status))
|
||||
.map(shapeRun)
|
||||
runsDb.getById = async (id) => (store.runs.has(id) ? shapeRun(store.runs.get(id)) : undefined)
|
||||
runsDb.listScheduledFor = async (definitionId) =>
|
||||
[...store.runs.values()]
|
||||
.filter((r) => r.definition_id === definitionId && r.status === 'scheduled' && !r.started_at)
|
||||
.map((r) => ({ id: r.id, version_id: r.version_id, scheduled_for: r.scheduled_for }))
|
||||
runsDb.repinScheduled = async (definitionId, versionId) => {
|
||||
let moved = 0
|
||||
for (const run of store.runs.values()) {
|
||||
if (run.definition_id !== definitionId) continue
|
||||
if (run.status !== 'scheduled' || run.started_at) continue
|
||||
if (run.version_id === versionId) continue
|
||||
run.version_id = versionId
|
||||
moved += 1
|
||||
}
|
||||
return moved
|
||||
}
|
||||
runsDb.materialise = async (run) => {
|
||||
const key = occurrenceKey(run.definition_id, run.scope, run.scheduled_for)
|
||||
if (store.occurrences.has(key)) return null // the UNIQUE index, doing its job
|
||||
@@ -595,3 +610,56 @@ test('the list filters by state, and an unknown id is 404 rather than 500', asyn
|
||||
const bad = await call(ctrl.get, { params: { id: 'not-a-number' } })
|
||||
assert.equal(bad.statusCode, 400)
|
||||
})
|
||||
|
||||
|
||||
test('publishing re-pins the occurrences that have not started, and says how many', async () => {
|
||||
// The case an operator meets on their SECOND edit of any recurring event: a
|
||||
// fortnight of occurrences is already on the calendar, each carrying the spec
|
||||
// as it was. Left alone, an edit reaches none of them and the only recourse --
|
||||
// cancelling each -- makes the occurrence vanish rather than come back, because
|
||||
// a cancelled row still holds its slot in `uq_evrun_occurrence`.
|
||||
const created = await createDraft()
|
||||
const id = created.body.event.id
|
||||
await call(ctrl.publish, { params: { id: String(id) } })
|
||||
|
||||
const ahead = await call(ctrl.startRun, {
|
||||
params: { id: String(id) },
|
||||
body: { scope: 'ahead', scheduledFor: '2026-12-24T20:00:00Z' },
|
||||
})
|
||||
assert.equal(ahead.statusCode, 201)
|
||||
const aheadId = ahead.body.run.id
|
||||
const v1 = store.runs.get(aheadId).version_id
|
||||
|
||||
// A second occurrence, this one already under way. Its pin is what makes it
|
||||
// explicable afterwards, so it must not move.
|
||||
const inFlight = await call(ctrl.startRun, {
|
||||
params: { id: String(id) },
|
||||
body: { scope: 'inflight', scheduledFor: '2026-12-25T20:00:00Z' },
|
||||
})
|
||||
const inFlightId = inFlight.body.run.id
|
||||
store.runs.get(inFlightId).status = 'running'
|
||||
store.runs.get(inFlightId).started_at = new Date()
|
||||
|
||||
const republished = await call(ctrl.publish, { params: { id: String(id) } })
|
||||
assert.equal(republished.statusCode, 200)
|
||||
assert.equal(republished.body.version, 2)
|
||||
assert.equal(republished.body.repinned, 1)
|
||||
|
||||
assert.equal(store.runs.get(aheadId).version_id, republished.body.versionId)
|
||||
assert.notEqual(store.runs.get(aheadId).version_id, v1)
|
||||
assert.equal(store.runs.get(inFlightId).version_id, v1)
|
||||
|
||||
// The move is on the run's own log, because "which version did this actually
|
||||
// use" is the first question an audit asks.
|
||||
const line = store.log.find((l) => l.run_id === aheadId && l.detail?.repinned)
|
||||
assert.equal(line.detail.fromVersionId, v1)
|
||||
assert.equal(line.detail.toVersionId, republished.body.versionId)
|
||||
})
|
||||
|
||||
test('re-publishing with nothing scheduled ahead re-pins nothing', async () => {
|
||||
const created = await createDraft()
|
||||
const id = created.body.event.id
|
||||
await call(ctrl.publish, { params: { id: String(id) } })
|
||||
const again = await call(ctrl.publish, { params: { id: String(id) } })
|
||||
assert.equal(again.body.repinned, 0)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user