feat(engagement): the email channel on the engine, and the Teams migration (engagement Phase 6)
Email becomes a DeliveryChannel driven by rules, and the Team pipeline stops being
its own thing. `teamNotify.forumPost` now emits an event; a rule decides who is
mailed, through which template, and how often at most. One walk goes forum write
-> events.emit -> rule -> outbox -> worker -> email channel -> template -> SMTP.
Seven decisions settled by the org lead before any code:
- email only moves; the push tickle and the Discord bridge stay direct calls
- the EVENT carries its access-checked audience, and `members` resolves to it
- the four Team rules are seeded DISABLED, with an admin banner and a note
- team_notification_prefs stays, read by the engine as a scoped preference
- the payload wins and a structural projection fills the gaps
- the digest keeps computing at send time; only its state generalizes
- an unsubscribe token turns off the channel it names, and nothing else
Three defects found while building it:
- `email.button` never absolutized its href, while image and itemList both
did. Every rule-driven CTA would have been a dead relative link, because a
trigger's url variables are validated site-relative by construction.
- Phase 4a enqueued digest-mode recipients for a drain that Phase 6 decided
not to build. An outbox row snapshots the payload and so has none of the
three properties the digest design exists for, including the security one.
- the digest's send-log row carried no address_hash while the instant row
beside it did, which would have made half the mail uncorrelatable in Phase 9.
Also: engagement_digest_state + a replay-safe backfill, engagement_outbox.scope_key,
a v2 unsubscribe token that still verifies v1 forever, and the canonical
/public/engagement/unsubscribe pair with the old /public/teams path kept
permanently — mail is not editable once sent.
Verified with 1464 server tests, 324 client tests, and a live rig (MariaDB +
Mailpit + a real Team) covering the instant mail, the digest, the generic
template, a pre-migration unsubscribe link and the backfill's replay-safety.
Docs: RunicGateway/docs#TBD
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
408
server/test/engagementEmail.test.js
Normal file
408
server/test/engagementEmail.test.js
Normal file
@@ -0,0 +1,408 @@
|
||||
// ── The email channel on the engine (ENGAGEMENT.md Phase 6) ────────────────
|
||||
//
|
||||
// The phase's own acceptance criteria, plus the four things building it showed
|
||||
// were worth pinning:
|
||||
//
|
||||
// • a scoped preference is what decides a Team-scoped event, and it REPLACES
|
||||
// the stream-level one — intersecting would silence every existing subscriber
|
||||
// • the structural projection fills only what the payload did not
|
||||
// • a `members` audience resolves to the set the EVENT carried
|
||||
// • core's four seeded rules exist, are all disabled, and are seeded once
|
||||
//
|
||||
// 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 registries = require('../src/modules/registries')
|
||||
const channels = require('../src/engagement/channels')
|
||||
const scopedPrefs = require('../src/engagement/scopedPrefs')
|
||||
const projection = require('../src/engagement/projection')
|
||||
const audiences = require('../src/engagement/audiences')
|
||||
const engine = require('../src/engagement/engine')
|
||||
const emailChannel = require('../src/engagement/emailChannel')
|
||||
const coreRules = require('../src/engagement/coreRules')
|
||||
const templates = require('../src/engagement/templates')
|
||||
const mailer = require('../src/utils/mailer')
|
||||
const unsubscribeToken = require('../src/utils/unsubscribeToken')
|
||||
const engagementEmit = require('../src/utils/engagementEmit')
|
||||
const rulesDb = require('../src/model/engagement/engagementRules.db')
|
||||
const recipients = require('../src/model/engagement/engagementRecipients.db')
|
||||
const settingsDb = require('../src/model/settings/settings.db')
|
||||
const teamNotifyModel = require('../src/model/teams/teamNotify.model')
|
||||
const unsubCtrl = require('../src/router/v1/public/engagement.controller')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
// Requiring this is what registers core's channels, transports and the `team`
|
||||
// scope provider — the one door (engagement/index.js's header). It does NOT
|
||||
// register core's TRIGGERS: those come from `registries.registerCore()`, which
|
||||
// app.js calls, and the split is deliberate — a trigger is a module-facing
|
||||
// declaration and a channel is an internal sink.
|
||||
require('../src/engagement')
|
||||
registries.registerCore()
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
const TRIGGER = 'team.forum.post'
|
||||
|
||||
let world
|
||||
|
||||
beforeEach(() => {
|
||||
world = { mails: [], teamPrefs: new Map(), storedModes: new Map(), inserted: [], settings: new Map() }
|
||||
|
||||
patch(mailer, 'sendNotification', async (msg) => {
|
||||
world.mails.push(msg)
|
||||
return { ok: true, transport: 'smtp' }
|
||||
})
|
||||
patch(templates, 'renderByKey', async (key, values) => ({
|
||||
subject: `[${key}] ${values.title || ''}`,
|
||||
html: '<p>x</p>',
|
||||
text: 'x',
|
||||
missing: [],
|
||||
values,
|
||||
}))
|
||||
patch(recipients, 'addressFor', async (userId) => ({ address: `u${userId}@example.test` }))
|
||||
patch(recipients, 'filterActive', async (ids) => ids)
|
||||
patch(recipients, 'storedModes', async () => world.storedModes)
|
||||
patch(teamNotifyModel, 'prefsForTeam', async (userIds, teamId) =>
|
||||
userIds
|
||||
.map((id) => world.teamPrefs.get(`${id}|${teamId}`))
|
||||
.filter(Boolean))
|
||||
patch(rulesDb, 'getById', async () => ({
|
||||
id: 1,
|
||||
trigger_id: TRIGGER,
|
||||
template_keys: { email: 'notify.team-post' },
|
||||
}))
|
||||
})
|
||||
afterEach(restore)
|
||||
|
||||
const outboxRow = (over = {}) => ({
|
||||
id: 1,
|
||||
rule_id: 1,
|
||||
trigger_id: TRIGGER,
|
||||
user_id: 11,
|
||||
channel: 'email',
|
||||
subject_key: 'The Silver Hand',
|
||||
scope_key: 'team:1',
|
||||
payload: { teamName: 'The Silver Hand', authorName: 'Ten', threadTitle: 'Raid', postUrl: '/g/1?thread=7' },
|
||||
...over,
|
||||
})
|
||||
|
||||
// ── deliver ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('a delivered row renders its rule’s template and sends to the user’s address', async () => {
|
||||
const result = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(world.mails[0].to, 'u11@example.test')
|
||||
assert.match(world.mails[0].rendered.subject, /^\[notify\.team-post\]/)
|
||||
})
|
||||
|
||||
test('a rule naming no template falls back to the generic one, which is what makes a new trigger mailable', async () => {
|
||||
patch(rulesDb, 'getById', async () => ({ id: 1, trigger_id: TRIGGER, template_keys: {} }))
|
||||
await emailChannel.deliver(outboxRow())
|
||||
assert.match(world.mails[0].rendered.subject, /^\[notify\.event\]/)
|
||||
})
|
||||
|
||||
// Terminal, not retryable. Retrying does not give somebody an address, and a
|
||||
// banned account will not be un-banned by a five-minute backoff.
|
||||
test('a user with no deliverable address is a terminal failure, not a retry', async () => {
|
||||
patch(recipients, 'addressFor', async () => null)
|
||||
const result = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.retry, undefined)
|
||||
assert.equal(world.mails.length, 0)
|
||||
})
|
||||
|
||||
// A throw would be read by the worker as a transient failure and retried five
|
||||
// times, so an unrenderable template would become five identical rows in the send
|
||||
// log instead of one honest terminal one.
|
||||
test('deliver never throws — a render failure is classified, not propagated', async () => {
|
||||
patch(templates, 'renderByKey', async () => { throw new Error('blocks are broken') })
|
||||
const result = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(result.ok, false)
|
||||
assert.match(result.detail, /blocks are broken/)
|
||||
})
|
||||
|
||||
test('the send log gets a hash of the address and never the address', async () => {
|
||||
const result = await emailChannel.deliver(outboxRow())
|
||||
assert.match(result.addressHash, /^[0-9a-f]{64}$/)
|
||||
assert.equal(result.addressHash.includes('@'), false)
|
||||
})
|
||||
|
||||
// A bounce arrives with an address, not with a spelling. Two spellings of one
|
||||
// mailbox must hash to one row or the correlation Phase 9 needs cannot be made.
|
||||
test('the address hash is case-folded, so a bounce can be correlated', async () => {
|
||||
const a = await emailChannel.deliver(outboxRow())
|
||||
patch(recipients, 'addressFor', async () => ({ address: ' U11@Example.Test ' }))
|
||||
const b = await emailChannel.deliver(outboxRow())
|
||||
assert.equal(a.addressHash, b.addressHash)
|
||||
})
|
||||
|
||||
// ── The unsubscribe link ───────────────────────────────────────────────────
|
||||
|
||||
test('every mail carries both unsubscribe urls, and they are not the same url', async () => {
|
||||
await emailChannel.deliver(outboxRow())
|
||||
const { unsubscribeUrl, unsubscribeApiUrl } = world.mails[0]
|
||||
assert.notEqual(unsubscribeUrl, unsubscribeApiUrl)
|
||||
// The header one has to be an ENDPOINT — a one-click client POSTs to it without
|
||||
// rendering anything — and the body one has to be a page a human can read first.
|
||||
assert.match(unsubscribeApiUrl, /\/api\/v1\/public\/engagement\/unsubscribe\//)
|
||||
assert.match(unsubscribeUrl, /\/unsubscribe\//)
|
||||
})
|
||||
|
||||
test('the link’s token names the email channel and the event’s SCOPE, not its subject', async () => {
|
||||
await emailChannel.deliver(outboxRow())
|
||||
const token = world.mails[0].unsubscribeApiUrl.split('/').pop()
|
||||
assert.deepEqual(unsubscribeToken.verify(token), {
|
||||
userId: 11, channel: 'email', scopeKey: 'team:1', version: 2,
|
||||
})
|
||||
// `subject_key` is the Team's NAME, which a rename changes. Signing over it
|
||||
// would orphan every link in a mailbox the first time staff renamed a guild.
|
||||
assert.equal(token.includes('Silver'), false)
|
||||
})
|
||||
|
||||
test('a scope the token format cannot carry costs the link, not the mail', async () => {
|
||||
const result = await emailChannel.deliver(outboxRow({ scope_key: 'NOT A SCOPE' }))
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(world.mails[0].unsubscribeUrl, null)
|
||||
})
|
||||
|
||||
// ── The projection (§4.6.1 property 1) ─────────────────────────────────────
|
||||
|
||||
test('the projection fills only what the payload left out', () => {
|
||||
const declaration = registries.eventTrigger(TRIGGER)
|
||||
assert.ok(declaration, 'core registers the four Team triggers')
|
||||
|
||||
const values = projection.project(TRIGGER, { teamName: 'X', threadTitle: 'Raid', postUrl: '/g/1' })
|
||||
assert.equal(values.threadTitle, 'Raid') // untouched
|
||||
assert.equal(values.title, declaration.label) // supplied
|
||||
assert.equal(values.intro, declaration.description)
|
||||
assert.equal(values.actionUrl, '/g/1') // the first declared url with a value
|
||||
})
|
||||
|
||||
// `news.post` and `team.announcement` both declare their own `title`. A
|
||||
// projection that overwrote it would replace a real headline with a category
|
||||
// label — on the one variable every generic template puts in the subject line.
|
||||
test('a payload that declares its own title keeps it', () => {
|
||||
const values = projection.project('team.announcement', { teamName: 'X', title: 'Siege moved' })
|
||||
assert.equal(values.title, 'Siege moved')
|
||||
})
|
||||
|
||||
test('an unregistered trigger still renders from its snapshot rather than being refused', () => {
|
||||
const values = projection.project('gone.away', { title: 'kept' })
|
||||
assert.equal(values.title, 'kept')
|
||||
assert.deepEqual(values.items, [])
|
||||
})
|
||||
|
||||
// ── The event-carried audience (decision 2) ────────────────────────────────
|
||||
|
||||
test('a members audience resolves to the recipient set the event carried', async () => {
|
||||
const resolved = await audiences.resolveForRule(
|
||||
{ audience: 'members', audience_segment_id: null },
|
||||
{ triggerId: TRIGGER, recipientUserIds: [11, 12] },
|
||||
)
|
||||
assert.deepEqual(resolved.userIds, [11, 12])
|
||||
assert.equal(resolved.ceiling, 'members')
|
||||
assert.equal(resolved.dormant, false)
|
||||
})
|
||||
|
||||
test('a members audience with no carried set and no segment still reaches nobody', async () => {
|
||||
const resolved = await audiences.resolveForRule(
|
||||
{ audience: 'members', audience_segment_id: null },
|
||||
{ triggerId: TRIGGER },
|
||||
)
|
||||
assert.deepEqual(resolved.userIds, [])
|
||||
assert.match(resolved.reason, /needs a segment/)
|
||||
})
|
||||
|
||||
// The carried set is a NARROWING input. It names who the event is about; it does
|
||||
// not raise what a rule is allowed to reach.
|
||||
test('a carried audience is still filtered for account status', async () => {
|
||||
patch(recipients, 'filterActive', async (ids) => ids.filter((id) => id !== 12))
|
||||
const resolved = await audiences.resolveForRule(
|
||||
{ audience: 'members', audience_segment_id: null },
|
||||
{ triggerId: TRIGGER, recipientUserIds: [11, 12] },
|
||||
)
|
||||
assert.deepEqual(resolved.userIds, [11])
|
||||
})
|
||||
|
||||
test('and it is still under the trigger’s G24 ceiling', () => {
|
||||
// `members` is what the four Team triggers ceiling at, so a carried set can
|
||||
// never be given `authenticated` by a rule that names one.
|
||||
assert.equal(audiences.permitted(TRIGGER, 'members'), true)
|
||||
assert.equal(audiences.permitted(TRIGGER, 'authenticated'), false)
|
||||
})
|
||||
|
||||
test('the emit contract refuses an audience that is not a list of user ids', () => {
|
||||
const bad = (recipientUserIds) =>
|
||||
assert.throws(() => engagementEmit.emit('core', TRIGGER, {
|
||||
data: { teamName: 'X', authorName: 'A', threadTitle: 'T' },
|
||||
recipientUserIds,
|
||||
}))
|
||||
bad('11')
|
||||
bad([0])
|
||||
bad([1.5])
|
||||
bad(new Array(6000).fill(1).map((_, i) => i + 1))
|
||||
})
|
||||
|
||||
// ── Scoped preferences (decision 4) ────────────────────────────────────────
|
||||
|
||||
const teamPref = (userId, teamId, over) => world.teamPrefs.set(`${userId}|${teamId}`, {
|
||||
user_id: userId, muted: 0, email_mode: 'off', ...over,
|
||||
})
|
||||
|
||||
test('a Team-scoped email event is decided by team_notification_prefs', async () => {
|
||||
teamPref(11, 1, { email_mode: 'immediate' })
|
||||
teamPref(12, 1, { email_mode: 'off' })
|
||||
const eligible = await engine.subscribedTo([11, 12], TRIGGER, 'email', 'team:1')
|
||||
assert.deepEqual(eligible, [11])
|
||||
})
|
||||
|
||||
// **The heart of decision 4.** `notification_channel_prefs` holds a row only
|
||||
// where a user expressed something, absence means the channel default, and
|
||||
// email's is `off`. Nobody has ever expressed a stream-level opinion about a Team
|
||||
// trigger — the screen that would let them is Phase 3's and the preference
|
||||
// predates it. So intersecting the two would resolve every existing Team-email
|
||||
// subscriber to `off` and silence the live pipeline on the migrating deploy.
|
||||
test('the scoped preference REPLACES the stream one — it does not intersect with it', async () => {
|
||||
teamPref(11, 1, { email_mode: 'immediate' })
|
||||
world.storedModes = new Map() // no stream-level row: the state of every real user
|
||||
assert.equal(channels.defaultMode('email'), 'off')
|
||||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [11])
|
||||
})
|
||||
|
||||
test('a per-Team mute silences every channel, not only the one that carries content', async () => {
|
||||
teamPref(11, 1, { muted: 1, email_mode: 'immediate' })
|
||||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [])
|
||||
world.storedModes = new Map([[11, 'instant']])
|
||||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'push', 'team:1'), [])
|
||||
})
|
||||
|
||||
test('a scope says nothing about push, so the stream preference decides it', async () => {
|
||||
teamPref(11, 1, { email_mode: 'off' })
|
||||
world.storedModes = new Map([[11, 'instant']])
|
||||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'push', 'team:1'), [11])
|
||||
})
|
||||
|
||||
test('an unscoped event is decided by the stream preference alone', async () => {
|
||||
teamPref(11, 1, { email_mode: 'immediate' })
|
||||
world.storedModes = new Map()
|
||||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', null), [])
|
||||
})
|
||||
|
||||
// Fails OPEN, and the practical effect is that nothing is sent rather than that
|
||||
// everybody is: the stream-level default is `off`. Failing closed would instead
|
||||
// drop an unrelated IDOC warning because a Team preference query timed out.
|
||||
test('a scope provider that throws leaves the stream preference in charge', async () => {
|
||||
patch(teamNotifyModel, 'prefsForTeam', async () => { throw new Error('db is on fire') })
|
||||
world.storedModes = new Map([[11, 'instant']])
|
||||
assert.deepEqual(await engine.subscribedTo([11], TRIGGER, 'email', 'team:1'), [11])
|
||||
})
|
||||
|
||||
test('an unparseable or unclaimed scope is "no scope", never some other scope', async () => {
|
||||
assert.equal(scopedPrefs.parse('team:1').prefix, 'team')
|
||||
assert.equal(scopedPrefs.parse('team:'), null)
|
||||
assert.equal(scopedPrefs.parse(':1'), null)
|
||||
assert.equal(scopedPrefs.parse(''), null)
|
||||
assert.equal((await scopedPrefs.resolve([11], 'email', 'nosuch:1')).size, 0)
|
||||
})
|
||||
|
||||
// ── The one-click unsubscribe (decision 7) ─────────────────────────────────
|
||||
|
||||
test('a v2 email token turns off that Team’s email and leaves its push alone', async () => {
|
||||
const writes = []
|
||||
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(['email', ...a]))
|
||||
patch(teamNotifyModel, 'mute', async (...a) => writes.push(['mute', ...a]))
|
||||
await unsubCtrl.applyClaim({ userId: 11, channel: 'email', scopeKey: 'team:1' })
|
||||
assert.deepEqual(writes, [['email', 11, 1, 'off']])
|
||||
})
|
||||
|
||||
// The acceptance criterion: a link from a mail sent BEFORE the migration still
|
||||
// works. It arrives at the old path, verifies as a v1 token, and turns off the
|
||||
// email it was labelled as turning off.
|
||||
test('a pre-migration link still unsubscribes, through the same handler', async () => {
|
||||
const writes = []
|
||||
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
|
||||
const legacy = unsubscribeToken.signLegacy(11, 1)
|
||||
const res = { json: (body) => { res.body = body } }
|
||||
await unsubCtrl.unsubscribe({ params: { token: legacy } }, res)
|
||||
assert.deepEqual(res.body, { ok: true })
|
||||
assert.deepEqual(writes, [[11, 1, 'off']])
|
||||
})
|
||||
|
||||
// An oracle for which (user, scope) pairs exist would be a real disclosure on an
|
||||
// endpoint with no session behind it.
|
||||
test('a forged token is answered exactly like a real one', async () => {
|
||||
const writes = []
|
||||
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
|
||||
const res = { json: (body) => { res.body = body } }
|
||||
await unsubCtrl.unsubscribe({ params: { token: '2.11.email.team:1.AAAAAAAAAAAAAAAAAAAAAA' } }, res)
|
||||
assert.deepEqual(res.body, { ok: true })
|
||||
assert.deepEqual(writes, [])
|
||||
})
|
||||
|
||||
test('a GET on the unsubscribe endpoint mutates nothing and lands on the page', () => {
|
||||
const writes = []
|
||||
patch(teamNotifyModel, 'setEmailMode', async (...a) => writes.push(a))
|
||||
let redirected = null
|
||||
unsubCtrl.unsubscribeLanding(
|
||||
{ params: { token: unsubscribeToken.sign(11, 'email', 'team:1') } },
|
||||
{ redirect: (code, url) => { redirected = { code, url } } },
|
||||
)
|
||||
assert.equal(redirected.code, 302)
|
||||
assert.match(redirected.url, /\/unsubscribe\//)
|
||||
assert.deepEqual(writes, [], 'a link scanner must not be able to unsubscribe anybody')
|
||||
})
|
||||
|
||||
// ── The seeded rules (decision 3) ──────────────────────────────────────────
|
||||
|
||||
test('core seeds a rule for each Team trigger, and every one of them is OFF', async () => {
|
||||
patch(settingsDb, 'get', async () => null)
|
||||
patch(settingsDb, 'set', async (k, v) => world.settings.set(k, v))
|
||||
patch(rulesDb, 'insert', async (rule) => { world.inserted.push(rule); return world.inserted.length })
|
||||
|
||||
const summary = await coreRules.seedTeamRules()
|
||||
assert.equal(summary.inserted, 4)
|
||||
assert.deepEqual(
|
||||
world.inserted.map((r) => r.trigger_id).sort(),
|
||||
['team.announcement', 'team.forum.post', 'team.leadership.changed', 'team.member.joined'],
|
||||
)
|
||||
// The invariant the org lead chose to honour rather than carve an exception
|
||||
// into: nothing is seeded on. Team email resumes when an operator switches one
|
||||
// on, and the release note says so.
|
||||
assert.equal(world.inserted.every((r) => r.enabled === 0), true)
|
||||
// `members`, which is what resolves to the carried recipient set. Anything
|
||||
// wider would be refused by the trigger's own ceiling anyway.
|
||||
assert.equal(world.inserted.every((r) => r.audience === 'members'), true)
|
||||
assert.equal(world.inserted.every((r) => r.channels.includes('email')), true)
|
||||
})
|
||||
|
||||
test('every seeded rule names a template that actually exists', () => {
|
||||
const seeded = new Set(coreRules.RULES.flatMap((r) => Object.values(r.template_keys)))
|
||||
const shipped = new Set(require('../src/engagement/templateSeeds').SEEDS.map((s) => s.key))
|
||||
for (const key of seeded) assert.equal(shipped.has(key), true, `${key} is not a shipped template`)
|
||||
})
|
||||
|
||||
// Seeded once, not ensured: an operator who deletes a rule must not find it back
|
||||
// after a restart, and one they enabled must not be reset to off.
|
||||
test('a second boot seeds nothing', async () => {
|
||||
patch(settingsDb, 'get', async () => '2026-08-29T00:00:00.000Z')
|
||||
patch(rulesDb, 'insert', async () => { throw new Error('must not insert') })
|
||||
const summary = await coreRules.seedTeamRules()
|
||||
assert.equal(summary.inserted, 0)
|
||||
assert.equal(summary.skipped, 4)
|
||||
})
|
||||
Reference in New Issue
Block a user