fix(engagement): two defects the Phase 11b live walk found in core
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 37s
PR Checks / client-build (pull_request) Successful in 45s
PR Checks / server-tests (pull_request) Successful in 5m30s

Both are invisible to a fixture and loud on a real database, which is why the
walk is the phase's acceptance rather than a formality.

1. registerEngagementSeeds validated `max_sends_per_hour` and then dropped it
   from the normalized rule. The column is NOT NULL, so every one of the 25
   module-seeded rules failed to insert at boot. The registry test asserted the
   REJECTION of a bad ceiling and never that a good one survives; it now asserts
   the normalized rule against `engagementRules.db.insert`'s own column list, so
   the next field added is covered the day it is added.

2. The cooldown claim runs inside the engine's per-channel loop and its key was
   (rule, user, subject). So the first channel of a rule claimed the cooldown and
   every later one was reported as cooled -- and `inapp` is ranked first
   deliberately, so a rule naming email + in-app delivered the inbox item and
   silently never the mail. Core's own `news.post` rule has that shape. Phase
   11b's decision 8 requires the letter and the inbox item to fire together.

   `channel` joins the PRIMARY KEY (the org lead's decision 12: a cooldown is per
   delivery, not per occasion). Migrated in place behind an information_schema
   guard, because MariaDB has no conditional form of a key change and replaying
   schema.sql would otherwise fail on every boot after the first.

1551 core tests green; both new tests verified by reverting each fix in turn.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-01 07:12:09 -05:00
parent c3783f56f1
commit c8d45733b6
6 changed files with 118 additions and 17 deletions

View File

@@ -149,6 +149,32 @@ test('a rule must carry a per-hour ceiling', () => {
assert.match(err, /max_sends_per_hour/)
})
test('a normalized rule carries every column the insert reads', () => {
// Refusing a bad ceiling and then DROPPING a good one are different bugs, and
// the first test cannot see the second: `engagementRules.db.insert` binds a
// fixed column list, so a field validated and not carried through arrives as
// NULL and fails the whole group at boot — on a real database only. Asserted
// against the column list itself rather than one field, because the next
// field added to the declaration is the next one that can be forgotten here.
assert.equal(trySeeds('demo', {
templates: [tpl()],
ruleGroups: [{ key: 'v1', rules: [rule({ delay_seconds: 60, cancel_on: ['demo.house.refreshed'] })] }],
}), null)
const seeded = registries.engagementSeedsFor('demo').ruleGroups[0].rules[0]
for (const column of [
'trigger_id', 'name', 'enabled', 'audience', 'audience_segment_id', 'max_sends_per_hour',
'channels', 'template_keys', 'conditions', 'cooldown_seconds', 'delay_seconds', 'cancel_on',
'updated_by',
]) {
assert.ok(column in seeded, `normalized rule is missing "${column}"`)
assert.notEqual(seeded[column], undefined, `normalized rule leaves "${column}" undefined`)
}
assert.equal(seeded.max_sends_per_hour, 200)
assert.equal(seeded.delay_seconds, 60)
assert.deepEqual(seeded.cancel_on, ['demo.house.refreshed'])
})
test('registering twice is a collision, not an addition', () => {
assert.equal(trySeeds('demo', { templates: [tpl()] }), null)
assert.match(trySeeds('demo', { templates: [tpl({ key: 'demo.other' })] }), /already registered/)