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>
114 lines
4.6 KiB
JavaScript
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'])
|
|
})
|