// ── Retention: the engagement schema's sweep (ENGAGEMENT.md Phase 14) ────── // // The phase's acceptance line is that every one of the four tables has a stated // policy — a sweep with a horizon, or a recorded decision that it does not // expire — and that the cooldown horizon is CHECKED against the longest enabled // rule rather than picked. Both are pinned here, plus the three things building // it showed are silent when wrong: // // • **the outbox sweep is terminal-only.** A `scheduled` row is a message this // deployment still intends to send; `delay_seconds` can legitimately put one // a day out. Sweeping by age alone would cancel sends nobody cancelled, and // the operator would see only that the mail never arrived. // • **`reclaimStale` has to give up.** `MAX_ATTEMPTS` is consulted only on a // graceful `retry` outcome, so before Phase 14 a send that killed the // process mid-flight cycled sending → scheduled → sending forever, never // reached a terminal status, and was therefore never eligible for ANY // retention sweep. The bound depends on this. // • **an unreadable setting means the default, not an exception.** The sweep // runs on a timer with nobody watching. // // Point the DB at a closed port before requiring anything: the registries reach // utils/discordAnnounce, which builds the pool at require time. 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 retention = require('../src/model/engagement/engagementRetention.model') const prune = require('../src/utils/engagementRetentionPrune') const cooldownsDb = require('../src/model/engagement/engagementCooldowns.db') const outboxDb = require('../src/model/engagement/engagementOutbox.db') const sendsDb = require('../src/model/engagement/engagementSends.db') const settings = require('../src/model/settings/settings.model') const rulesDb = require('../src/model/engagement/engagementRules.db') const db = require('../src/utils/db') after(() => db.close()) const saved = new Map() function patch(mod, name, fn) { if (!saved.has(mod)) saved.set(mod, new Map()) if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name]) mod[name] = fn } function restore() { for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn saved.clear() } let world beforeEach(() => { world = { settings: new Map(), longestCooldown: 0, // Every prune call, so a test can assert on the horizon it was given rather // than only on the fact that something was deleted. calls: { cooldowns: [], outbox: [], sends: [] }, deleted: { cooldowns: 0, outbox: 0, sends: 0 }, } patch(settings, 'get', async (key) => { if (!world.settings.has(key)) return null return world.settings.get(key) }) patch(settings, 'set', async (key, value) => { world.settings.set(key, value) }) patch(rulesDb, 'maxEnabledCooldownSeconds', async () => world.longestCooldown) patch(cooldownsDb, 'prune', async (before, limit) => { world.calls.cooldowns.push({ before, limit }) return world.deleted.cooldowns }) patch(outboxDb, 'pruneTerminal', async (before, limit) => { world.calls.outbox.push({ before, limit }) return world.deleted.outbox }) patch(sendsDb, 'prune', async (before, limit) => { world.calls.sends.push({ before, limit }) return world.deleted.sends }) }) afterEach(() => restore()) const daysBetween = (now, before) => Math.round((now.getTime() - before.getTime()) / 86400000) // ── The policy ───────────────────────────────────────────────────────────── test('defaults are the shipped policy when nothing is stored', async () => { const policy = await retention.get() assert.deepEqual(policy, { sends: 180, cooldowns: 30, outbox: 30 }) }) test('a stored value is used, and written back as a string', async () => { await retention.set({ sends: 365 }, 7) assert.equal(world.settings.get('engagement_sends_retain_days'), '365') assert.equal((await retention.get()).sends, 365) }) test('the PUT is sparse — an absent horizon is left alone', async () => { await retention.set({ sends: 90 }) await retention.set({ cooldowns: 45 }) const policy = await retention.get() assert.equal(policy.sends, 90, 'the earlier write survives the second call') assert.equal(policy.cooldowns, 45) assert.equal(policy.outbox, 30, 'and the untouched one is still the default') }) test('out of range is refused rather than clamped', async () => { // Clamping would leave the screen describing a policy the deployment is not // running, which is worse than a visible error. await assert.rejects(() => retention.set({ cooldowns: 1 }), /between 2 and 3650/) await assert.rejects(() => retention.set({ sends: 5 }), /between 7 and 3650/) await assert.rejects(() => retention.set({ outbox: 99999 }), /between 2 and 3650/) await assert.rejects(() => retention.set({ sends: 12.5 }), /whole number/) assert.equal(world.settings.size, 0, 'nothing was written') }) test('an unknown key in the body is ignored, not rejected', async () => { // So a client that posts the whole object back is not coupled to the list. await retention.set({ sends: 200, suppressions: 30, nonsense: 1 }) assert.equal((await retention.get()).sends, 200) assert.equal(world.settings.has('engagement_suppressions_retain_days'), false) }) test('a stored value outside the bounds falls back to the default', async () => { // A row written before the bounds existed, or by hand. world.settings.set('engagement_cooldowns_retain_days', '0') assert.equal((await retention.get()).cooldowns, 30) world.settings.set('engagement_cooldowns_retain_days', 'soon') assert.equal((await retention.get()).cooldowns, 30) }) test('an unreadable settings table yields defaults rather than throwing', async () => { patch(settings, 'get', async () => { throw new Error('pool is dead') }) assert.deepEqual(await retention.get(), { sends: 180, cooldowns: 30, outbox: 30 }) }) // ── The cooldown guard ───────────────────────────────────────────────────── test('the cooldown horizon is checked against the longest ENABLED rule', async () => { world.longestCooldown = 86400 // the validated maximum, one day const ok = await retention.checkCooldownHorizon(30) assert.equal(ok.ok, true) assert.equal(ok.message, null) // A horizon shorter than a live cooldown means a pruned row makes the next // fire a FIRST fire — the rule sends twice. const bad = await retention.checkCooldownHorizon(0.5) assert.equal(bad.ok, false) assert.match(bad.message, /can send twice/) assert.equal(bad.longestCooldownSeconds, 86400) }) test('the horizon equal to the longest cooldown is refused, not accepted', async () => { // Equality is the boundary where a row is pruned exactly as it stops being in // force; the check has to be <=, not <. world.longestCooldown = 2 * 86400 assert.equal((await retention.checkCooldownHorizon(2)).ok, false) assert.equal((await retention.checkCooldownHorizon(3)).ok, true) }) test('no enabled rule means no warning at any horizon', async () => { world.longestCooldown = 0 assert.equal((await retention.checkCooldownHorizon(2)).ok, true) }) // ── The sweep ────────────────────────────────────────────────────────────── test('one tick sweeps all three tables at their own horizons', async () => { const now = new Date('2026-09-01T03:00:00Z') const result = await prune.tick(now) assert.equal(daysBetween(now, world.calls.sends[0].before), 180) assert.equal(daysBetween(now, world.calls.cooldowns[0].before), 30) assert.equal(daysBetween(now, world.calls.outbox[0].before), 30) assert.deepEqual(result.warnings, []) }) test('each statement is bounded', async () => { await prune.tick(new Date()) for (const table of ['cooldowns', 'outbox', 'sends']) { assert.equal(world.calls[table][0].limit, prune.BATCH, `${table} is batched`) } }) test('a sweep repeats while its batches come back full, and stops', async () => { world.deleted.sends = prune.BATCH const result = await prune.tick(new Date()) assert.equal(world.calls.sends.length, prune.MAX_BATCHES, 'it stops rather than looping forever') assert.equal(result.sends, prune.BATCH * prune.MAX_BATCHES) }) test('one failing table does not stop the other two', async () => { patch(outboxDb, 'pruneTerminal', async () => { throw new Error('lock wait timeout') }) const result = await prune.tick(new Date()) assert.equal(result.outbox, 0) assert.equal(world.calls.cooldowns.length, 1, 'cooldowns still swept') assert.equal(world.calls.sends.length, 1, 'the send log still swept') }) test('the sweep runs even when the cooldown horizon is too short, and warns', async () => { // Deliberate: refusing to prune would trade a bounded, describable fault (one // rule may re-fire early) for the unbounded one this phase exists to end. world.longestCooldown = 86400 world.settings.set('engagement_cooldowns_retain_days', '2') const result = await prune.tick(new Date()) assert.equal(result.warnings.length, 0, 'two days clears a one-day cooldown') world.longestCooldown = 30 * 86400 // longer than any rule can actually save const second = await prune.tick(new Date()) assert.equal(second.warnings.length, 1) assert.equal(world.calls.cooldowns.length, 2, 'it swept anyway') }) test('an unreadable policy skips the run rather than sweeping on a guess', async () => { patch(retention, 'get', async () => { throw new Error('pool is dead') }) const result = await prune.tick(new Date()) assert.deepEqual(result, { cooldowns: 0, outbox: 0, sends: 0, warnings: [] }) assert.equal(world.calls.sends.length, 0, 'nothing was deleted') }) test('start/stop is idempotent and leaves no live timer', () => { prune.start() prune.start() prune.stop() prune.stop() }) // The SQL these depend on — terminal-only, the give-up-before-reclaim order and // the batch LIMIT — is proved against a real server in engagementRetentionSql.test.js. // It cannot be proved here: the db modules destructure `query` at require time, // so there is nothing left to stub, and a hand-rolled stand-in would prove only // that two readings of the manual agree (which is exactly the trap Phase 4a's // `foundRows` defect sprang).