Files
website/client/test/engagementRules.test.js
wtclaude 4b45eddb5d
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 31s
PR Checks / client-build (pull_request) Successful in 32s
PR Checks / server-tests (pull_request) Successful in 10m36s
feat(engagement): Admin - Engagement - Rules and Audiences (engagement Phase 4b)
The admin surface over the Phase 4a engine: two screens, twelve routes and the
reach preview. Nothing in the engine changed; what changed is that an operator
can now reach it.

Four decisions settled by the org lead before any code:

  - segments get their OWN nav entry, "Audiences", not a tab of the rules screen
  - the on/off switch is its own PATCH route, not a full PUT
  - the reach preview is a count only, on demand
  - a rule can be hard-deleted; the send log survives it

The switch is the one with real content in it. A PUT re-validates against the
registries as they are NOW, so the rules a re-validating toggle cannot switch
off are exactly the three an operator most wants stopped: a rule whose module
was uninstalled, one naming a channel that is gone, and one whose trigger has
since narrowed its ceiling under a saved audience. PATCH .../enabled writes one
column and always works. Switching ON unvalidated is safe because the engine
re-checks the ceiling at send time.

The preview calls the engine's own resolver rather than a second query that
agrees with it today, and answers a count and nothing else - the resolver's
output for a module-declared segment is a set of players derived from game data.
It reports `capped` at the 5000-row bound (the count is a floor, not a total),
`reason` for an `owner` audience (which resolves per event and has no advance
answer), and `permitted` so the editor cannot show a healthy number beside a
save the server will refuse.

Two defects found by walking it against a live server, both in Phase 4a's code:

  1. A rule pointing at a DORMANT segment read as healthy. listAnnotated asked
     only whether the segment ROW existed. The other shape of the same failure
     is a segment sitting exactly where it was whose every audience belongs to
     an uninstalled module: same outcome, nothing deleted. Uninstalling a module
     under an enabled rule produced a rule the screen showed as on and firing.
     The expression walk now lives in engagement/segments.js as
     `missingAudiences` and both lists ask it.
  2. "1 rule still use this segment" - the delete refusal pluralised the noun
     and not the verb, in the sentence an operator reads when told no.

Also: a rule's trigger is now a stated rule rather than an omission in the
UPDATE statement (its cooldowns, queued sends and history are all about one
trigger id); a condition tree the editor cannot render is shown read-only rather
than flattened, because flattening changes which events fire the rule; and
literals are coerced client-side to the type the trigger declared, with anything
that does not parse passed through unchanged so the server's refusal names the
variable.

Tests: 21 new server tests (test/engagementAdmin.test.js) and 25 client ones
(client/test/engagementRules.test.js), all green. The single failure in the
server suite (`the committed manifest matches the declarations in the tree`) is
the known Windows CRLF artifact and fails identically on clean edge.

Companion docs PR: docs#184.

- [x] AI-assisted: written with Claude Code (Opus)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-29 12:10:04 -05:00

278 lines
12 KiB
JavaScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
formFromRule,
ruleToPayload,
audienceChoicesFor,
segmentChoicesFor,
describeReach,
describeRule,
describeExpression,
notPlacementError,
conditionRowsFrom,
conditionsFromRows,
operatorsForType,
coerceLiteral,
humanSeconds,
} from '../src/lib/engagementRules.js'
// lib/engagementRules.js — what the two Engagement screens say and what they let
// an operator pick (ENGAGEMENT.md Phase 4b).
//
// None of this is a boundary: the server's `engagementRules.model` decides what
// may be saved and the engine re-checks the audience ceiling at send time. What
// is tested here is the part that would be wrong SILENTLY — a form that sends a
// string where the trigger declared an int, a composer that flattens a nested
// condition into one that fires on different events, an editor that offers an
// audience the save is going to refuse.
const CEILINGS = [
{ id: 'everyone', label: 'Everyone', permits: ['everyone', 'authenticated', 'subscribers', 'members', 'staff', 'owner'] },
{ id: 'authenticated', label: 'Signed-in users', permits: ['authenticated', 'subscribers', 'members', 'staff', 'owner'] },
{ id: 'subscribers', label: 'Subscribers', permits: ['subscribers'] },
{ id: 'members', label: 'A module list', permits: ['members'] },
{ id: 'staff', label: 'Staff', permits: ['staff'] },
{ id: 'owner', label: 'The person it is about', permits: ['owner'] },
]
const TRIGGER = {
id: 'uo.house.idoc_warning',
label: 'House approaching collapse',
ceiling: 'owner',
audience: 'owner',
subjectKey: 'house',
variables: [
{ name: 'house', type: 'string', required: true },
{ name: 'daysLeft', type: 'int', required: false },
{ name: 'insured', type: 'boolean', required: false },
],
}
const OPERATORS = [
{ cmp: 'eq', label: 'is', types: ['string', 'int', 'boolean'], arity: 1 },
{ cmp: 'gt', label: 'is greater than', types: ['int'], arity: 1 },
{ cmp: 'in', label: 'is one of', types: ['string', 'int'], arity: 'list' },
{ cmp: 'present', label: 'is present', types: ['string', 'int', 'boolean'], arity: 0 },
]
const row = (over = {}) => ({
id: 3,
trigger_id: 'uo.house.idoc_warning',
name: 'IDOC warning',
enabled: 1,
audience: 'owner',
audience_segment_id: null,
channels: ['email'],
template_keys: { email: 'idoc-warning' },
conditions: null,
cooldown_seconds: 86400,
delay_seconds: 0,
cancel_on: [],
max_sends_per_hour: 100,
...over,
})
// ── The form round trip ────────────────────────────────────────────────────
test('a rule row round-trips through the form without changing what it means', () => {
const payload = ruleToPayload(formFromRule(row()))
assert.equal(payload.triggerId, 'uo.house.idoc_warning')
assert.equal(payload.enabled, true)
assert.deepEqual(payload.channels, ['email'])
assert.deepEqual(payload.templateKeys, { email: 'idoc-warning' })
assert.equal(payload.cooldownSeconds, 86400)
assert.equal(payload.maxSendsPerHour, 100)
})
test('unticking a channel drops its template key, rather than sending one the server refuses', () => {
const form = formFromRule(row({ channels: ['email', 'push'], template_keys: { email: 'a', push: 'b' } }))
form.channels = ['email']
const payload = ruleToPayload(form)
// The server refuses `templateKeys` naming a channel the rule does not have.
// Leaving it in would produce an error about a field the operator cannot see.
assert.deepEqual(payload.templateKeys, { email: 'a' })
})
// ── The audience the editor may offer ──────────────────────────────────────
test('the editor offers only what the trigger ceiling permits', () => {
const choices = audienceChoicesFor(TRIGGER, CEILINGS).map((c) => c.id)
assert.deepEqual(choices, ['owner'])
})
test('a wider trigger offers more, in lattice order', () => {
const choices = audienceChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS).map((c) => c.id)
assert.deepEqual(choices, ['authenticated', 'subscribers', 'members', 'staff', 'owner'])
})
test('an unknown trigger offers nothing — failing closed, like the server', () => {
// This is a dormant rule, whose module has been uninstalled. Offering the full
// vocabulary would be the widening the whole ceiling design exists to prevent.
assert.deepEqual(audienceChoicesFor({ ...TRIGGER, ceiling: 'nonsense' }, CEILINGS), [])
assert.deepEqual(audienceChoicesFor(null, CEILINGS), [])
})
test('segments are filtered by their STORED ceiling, not re-derived', () => {
const segments = [
{ id: 1, name: 'Governors', ceiling: 'members' },
{ id: 2, name: 'Watchers', ceiling: 'authenticated' },
]
const wide = segmentChoicesFor({ ...TRIGGER, ceiling: 'authenticated' }, CEILINGS, segments)
assert.deepEqual(wide.map((s) => s.id), [1, 2])
const narrow = segmentChoicesFor({ ...TRIGGER, ceiling: 'members' }, CEILINGS, segments)
assert.deepEqual(narrow.map((s) => s.id), [1])
})
// ── The reach preview ──────────────────────────────────────────────────────
test('a capped count reads as a floor, never as a total', () => {
const said = describeReach({ count: 5000, capped: true, dormant: false, reason: null, permitted: true })
assert.match(said, /At least 5000/)
})
test('a count the trigger would refuse says so, instead of looking healthy', () => {
const said = describeReach({ count: 12, capped: false, dormant: false, reason: null, permitted: false })
assert.match(said, /will be refused/)
})
test('a dormant segment says why, rather than reading as "nobody"', () => {
const said = describeReach({ count: 0, dormant: true, reason: 'audience segment is dormant' })
assert.match(said, /dormant/)
})
test('an owner audience carries its reason forward', () => {
const said = describeReach({ count: 0, dormant: false, reason: 'event carries no ownerUserId', permitted: true })
assert.match(said, /ownerUserId/)
})
// ── Conditions ─────────────────────────────────────────────────────────────
test('operators narrow to the variable type that was picked', () => {
assert.deepEqual(operatorsForType(OPERATORS, 'boolean').map((o) => o.cmp), ['eq', 'present'])
assert.deepEqual(operatorsForType(OPERATORS, 'int').map((o) => o.cmp), ['eq', 'gt', 'in', 'present'])
})
test('a literal is coerced to the type the trigger DECLARED', () => {
// Every value in an HTML input is a string, and `{ cmp: 'gt', value: "5" }`
// against an int variable is refused by the server — rightly, because a
// comparison between a number and a string quietly never matches.
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'gt', value: '5' }], TRIGGER.variables)
assert.deepEqual(built, { variable: 'daysLeft', cmp: 'gt', value: 5 })
})
test('a value that does not parse is passed through, so the server names the field', () => {
// NOT NaN, and not 0: a rule that saves cleanly having silently compared
// against a number nobody typed is worse than a refusal that says which
// variable it was.
assert.equal(coerceLiteral('int', 'soon'), 'soon')
assert.equal(coerceLiteral('boolean', 'yes'), 'yes')
assert.equal(coerceLiteral('boolean', 'true'), true)
assert.equal(coerceLiteral('float', '1.5'), 1.5)
})
test('a list operator splits on commas and types each item', () => {
const built = conditionsFromRows('and', [{ variable: 'daysLeft', cmp: 'in', value: '1, 2, 3' }], TRIGGER.variables)
assert.deepEqual(built.value, [1, 2, 3])
})
test('present and absent carry no value at all', () => {
const built = conditionsFromRows('and', [{ variable: 'house', cmp: 'present', value: 'ignored' }], TRIGGER.variables)
assert.deepEqual(built, { variable: 'house', cmp: 'present' })
})
test('no rows means no conditions — not an empty group that matches nothing', () => {
assert.equal(conditionsFromRows('and', [], TRIGGER.variables), null)
assert.equal(conditionsFromRows('and', [{ variable: '', cmp: '' }], TRIGGER.variables), null)
})
test('a flat stored tree opens editable; a nested one opens read-only', () => {
const flat = conditionRowsFrom({
op: 'and',
nodes: [{ variable: 'house', cmp: 'eq', value: 'x' }, { variable: 'daysLeft', cmp: 'gt', value: 5 }],
})
assert.equal(flat.editable, true)
assert.equal(flat.rows.length, 2)
// `A AND (B OR C)` flattened to `A AND B AND C` fires on different events, and
// the operator would have no way to know the save had done it.
const nested = conditionRowsFrom({
op: 'and',
nodes: [
{ variable: 'house', cmp: 'eq', value: 'x' },
{ op: 'or', nodes: [{ variable: 'daysLeft', cmp: 'gt', value: 5 }] },
],
})
assert.equal(nested.editable, false)
assert.deepEqual(nested.rows, [])
})
test('a single stored comparison is one editable row', () => {
const one = conditionRowsFrom({ variable: 'house', cmp: 'eq', value: 'x' })
assert.equal(one.editable, true)
assert.deepEqual(one.rows, [{ variable: 'house', cmp: 'eq', value: 'x' }])
})
// ── Segment composition ────────────────────────────────────────────────────
test('a bare not is refused before it reaches the server', () => {
assert.ok(notPlacementError({ op: 'not', nodes: [{ audienceId: 'uo.governors' }] }))
assert.ok(notPlacementError({ op: 'or', nodes: [{ audienceId: 'a' }, { op: 'not', nodes: [{ audienceId: 'b' }] }] }))
})
test('a not under an "all of" is fine — that is the only universe that does not widen', () => {
assert.equal(
notPlacementError({
op: 'and',
nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }],
}),
null,
)
})
test('an expression describes itself with module labels where it has them', () => {
const byId = { 'uo.governors': { label: 'Governors' } }
const said = describeExpression(
{ op: 'and', nodes: [{ audienceId: 'uo.governors' }, { op: 'not', nodes: [{ audienceId: 'uo.flagged' }] }] },
byId,
)
assert.equal(said, 'Governors and not uo.flagged')
})
test('a leaf renders its parameters, so two rows built on the same audience are distinguishable', () => {
const said = describeExpression({ audienceId: 'uo.team.members', params: { teamId: 4 } }, {})
assert.equal(said, 'uo.team.members (teamId: 4)')
})
// ── The list summary ───────────────────────────────────────────────────────
test('a rule summarises to what it will do, and always names its hourly cap', () => {
const said = describeRule(row({ delay_seconds: 3600 }), { segmentsById: {} })
assert.match(said, /to owner/)
assert.match(said, /via email/)
assert.match(said, /after 1 hour/)
assert.match(said, /once per 1 day/)
assert.match(said, /100\/hour/)
})
test('a rule on a segment names the segment, not the ceiling column', () => {
// The `audience` column on such a rule holds the segment's ceiling, which is a
// fact about what it MAY reach and not about who it does.
const said = describeRule(row({ audience: 'members', audience_segment_id: 7 }), {
segmentsById: { 7: { name: 'Governors' } },
})
assert.match(said, /to Governors/)
})
test('humanSeconds picks the coarsest EXACT unit, and never rounds', () => {
assert.equal(humanSeconds(0), 'none')
assert.equal(humanSeconds(3600), '1 hour')
assert.equal(humanSeconds(86400), '1 day')
assert.equal(humanSeconds(7200), '2 hours')
assert.equal(humanSeconds(3660), '61 minutes')
assert.equal(humanSeconds(90), '90 seconds')
})