Files
website/server/test/eventSeries.test.js
wtclaude 6e73660b52
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 43s
PR Checks / server-tests (pull_request) Successful in 13m26s
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>
2026-09-02 16:10:16 -05:00

114 lines
4.6 KiB
JavaScript

// ── 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'])
})