Core's half of ENGAGEMENT.md Phase 11a: the two decisions the org lead settled before any code that land in core rather than in module-uo. Pairs with Module-uo#22 and docs#194. ## Decision 1 -- a seventh ceiling, `admin`, as a child of `staff` Phase 11's operator-facing triggers (uo.audit.staff_action, uo.economy.milestone, uo.world.saved) are described as admin-audience everywhere, and the narrowest value the lattice had was `staff` -- which ceilings.js defines as admin, editor AND moderator. Ceilinging them there would have let an operator save a rule that mails the staff audit digest to every moderator in it. `admin` is the ONLY genuine refinement in the tree -- every admin is staff, which is exactly the containment every other pair of branches lacks -- so it is a child rather than a seventh leaf, and permits/meet/meetAll needed no change beyond the new PARENT entry. **The one non-obvious consequence, and the reason for ROLE_CEILINGS.** notificationChannelPrefs' `visibleTo` asked `item.ceiling !== 'staff'`. That was correct while `staff` was the only role-gated value, and the day `admin` arrived it would have silently published every admin-ceilinged id -- the staff audit digest, the economy thresholds -- to every player's preferences screen by name. It now reads a TABLE (`ceilings.reachableBy`), so a ceiling added without an entry fails closed instead. An EDITOR is the viewer that tells the two rules apart, and the new tests use one. MODULE_API_VERSION -> 1.8.0 on both halves. Additive: every declaration valid under 1.7.0 is valid now and no stored value changes. ## Decision 5 -- 7.1 Q9: news.post gets an emitter, and it REPLACES the tickle `news.post` has been a declared payload contract with no caller since Phase 2, so a rule naming it could never fire. utils/newsNotify.js is the caller; announceIfNewlyPublished now calls it instead of pushDispatch.publish, gated on the same enqueueIfNeeded job id -- the single "newly published news" transition signal, not re-derived. **News push therefore stops on upgrade** until an operator enables the seeded rule. That is the org lead's decision, taken over keeping the raw call beside the emit "for one release": an exception with a deadline nobody owns, which Phase 6 already refused for Teams. The Rules screen gains a second migration notice naming news, and Phase 13's release note carries it as an upgrade step. **The seed needed its own one-shot key, and this is the trap worth recording.** `engagement_team_rules_seeded` is already stamped on every deployment that has booted since Phase 6, and the guard reads its presence -- so appending news to RULES would have seeded it on fresh installs only, and on exactly the upgrades that lose their raw push, never. One key per seed GROUP is now the rule; seedGroup() is the shared implementation and seedCoreRules() is what boot calls. Also fixes news.post's `postUrl` example, which named `/news/<slug>` -- a path App.jsx does not mount. An example is what the template editor previews and test-sends with, so a wrong one is a preview that looks right and a mail that is not. It is `/site/news`, the list, which is what the Discord and town-crier announcements have always linked. 1550 tests pass (16 new), 327 client tests pass, client builds, check:modules clean -- core still names no module identifier with module-uo now registering 24 UO-named triggers. Co-Authored-By: Claude <noreply@anthropic.com>
433 lines
18 KiB
JavaScript
433 lines
18 KiB
JavaScript
// ── Per-channel notification preferences (ENGAGEMENT.md Phase 3) ───────────
|
|
//
|
|
// The phase's acceptance criteria, one test apiece:
|
|
//
|
|
// • the shipped Android app's flat `{streams:[…]}` PUT still round-trips,
|
|
// INCLUDING the empty-array case its DTO comment warns about
|
|
// • a per-channel PUT sets `email` without touching `push`
|
|
// • a fresh user's email mode defaults `off`, and so does push
|
|
//
|
|
// …plus the two properties that make the projection safe to ship: the legacy
|
|
// wire shape is pinned BYTE-FOR-BYTE (the app cannot be changed from this side),
|
|
// and the invariant the two endpoints jointly maintain — a push pref with mode
|
|
// <> 'off' iff a `notification_subscriptions` row — is asserted from both
|
|
// directions rather than only from the one the code happens to take.
|
|
//
|
|
// 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 prefs = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.model')
|
|
const prefsDb = require('../src/model/notificationChannelPrefs/notificationChannelPrefs.db')
|
|
const subs = require('../src/model/notificationSubs/notificationSubs.model')
|
|
const subsDb = require('../src/model/notificationSubs/notificationSubs.db')
|
|
const notifCtrl = require('../src/router/v1/auth/notifications.controller')
|
|
const db = require('../src/utils/db')
|
|
|
|
after(() => db.close())
|
|
|
|
const USER = 7
|
|
const PLAYER = { id: USER, role: 'player' }
|
|
const ADMIN = { id: USER, role: 'admin' }
|
|
// Phase 11 added the `admin` ceiling beneath `staff`, so the interesting viewer
|
|
// is no longer "player vs staff" but the one INSIDE `staff` and outside `admin`.
|
|
const EDITOR = { id: USER, role: 'editor' }
|
|
|
|
// ── In-memory stand-ins for the two tables ─────────────────────────────────
|
|
//
|
|
// Both are stubbed at the `.db` layer, so the model's own fan-out logic — the
|
|
// part this phase actually adds — runs for real against them.
|
|
let prefRows // Map "<user> <id> <channel>" -> mode
|
|
let subRows // Set "<user> <id>"
|
|
|
|
function installStubs() {
|
|
prefRows = new Map()
|
|
subRows = new Set()
|
|
|
|
prefsDb.listByUser = async (userId) =>
|
|
[...prefRows.entries()]
|
|
.filter(([k]) => k.startsWith(`${userId} `))
|
|
.map(([k, mode]) => {
|
|
const [, streamId, channel] = k.split(' ')
|
|
return { stream_id: streamId, channel, mode }
|
|
})
|
|
.sort((a, b) => a.stream_id.localeCompare(b.stream_id) || a.channel.localeCompare(b.channel))
|
|
|
|
prefsDb.upsert = async (userId, streamId, channel, mode) => {
|
|
prefRows.set(`${userId} ${streamId} ${channel}`, mode)
|
|
}
|
|
|
|
prefsDb.offPushExcept = async (userId, keep) => {
|
|
for (const [k, mode] of prefRows.entries()) {
|
|
const [u, streamId, channel] = k.split(' ')
|
|
if (Number(u) !== userId || channel !== 'push' || mode === 'off') continue
|
|
if (!keep.includes(streamId)) prefRows.set(k, 'off')
|
|
}
|
|
}
|
|
|
|
subsDb.listByUser = async (userId) =>
|
|
[...subRows]
|
|
.filter((k) => k.startsWith(`${userId} `))
|
|
.map((k) => ({ stream_id: k.split(' ')[1] }))
|
|
.sort((a, b) => a.stream_id.localeCompare(b.stream_id))
|
|
|
|
subsDb.replaceForUser = async (userId, streams) => {
|
|
for (const k of [...subRows]) if (k.startsWith(`${userId} `)) subRows.delete(k)
|
|
for (const s of streams) subRows.add(`${userId} ${s}`)
|
|
}
|
|
subsDb.addForUser = async (userId, streamId) => subRows.add(`${userId} ${streamId}`)
|
|
subsDb.removeForUser = async (userId, streamId) => subRows.delete(`${userId} ${streamId}`)
|
|
}
|
|
|
|
// The channel registry is populated by requiring the subsystem's door, exactly
|
|
// as app.js does. Requiring `channels` alone gets the empty map — that is the
|
|
// design, and doing it the other way here would hide a boot-order regression.
|
|
function registerChannels() {
|
|
channels._reset()
|
|
delete require.cache[require.resolve('../src/engagement/coreChannels')]
|
|
// eslint-disable-next-line global-require
|
|
require('../src/engagement/coreChannels')
|
|
}
|
|
|
|
beforeEach(() => {
|
|
registries._reset()
|
|
registries.registerCore()
|
|
registerChannels()
|
|
installStubs()
|
|
})
|
|
|
|
afterEach(() => {
|
|
registries._reset()
|
|
channels._reset()
|
|
})
|
|
|
|
const mockRes = () => ({
|
|
statusCode: 200,
|
|
body: null,
|
|
status(c) { this.statusCode = c; return this },
|
|
json(b) { this.body = b; return this },
|
|
})
|
|
|
|
const item = (surface, id) => surface.items.find((i) => i.id === id)
|
|
|
|
// ── Acceptance: the shipped app's wire shape ───────────────────────────────
|
|
|
|
test('the legacy subscriptions PUT round-trips byte-for-byte', async () => {
|
|
const put = mockRes()
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'team.forum.post'] } }, put)
|
|
|
|
// Byte-for-byte: the response is `{ streams: [...] }` and nothing else. The
|
|
// Android DTO is frozen, so an extra key is as much a break as a missing one.
|
|
assert.deepEqual(Object.keys(put.body), ['streams'])
|
|
assert.deepEqual(put.body.streams.slice().sort(), ['news.post', 'team.forum.post'])
|
|
|
|
const get = mockRes()
|
|
await notifCtrl.getSubscriptions({ user: PLAYER }, get)
|
|
assert.deepEqual(Object.keys(get.body), ['streams'])
|
|
assert.deepEqual(get.body.streams, ['news.post', 'team.forum.post'])
|
|
})
|
|
|
|
test('the empty-array case its DTO comment warns about still clears the set', async () => {
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes())
|
|
|
|
const cleared = mockRes()
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: [] } }, cleared)
|
|
assert.deepEqual(cleared.body, { streams: [] })
|
|
|
|
// And the projection cleared with it — the failure this test exists to catch
|
|
// is a channel-prefs row left at 'instant' after the app said "none", which
|
|
// would resurrect the subscription the next time anything read the new table.
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
assert.equal(item(surface, 'news.post').modes.push, 'off')
|
|
assert.equal(subRows.size, 0)
|
|
})
|
|
|
|
test('unknown stream ids are still dropped, and are not mirrored either', async () => {
|
|
const res = mockRes()
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post', 'no.such.stream'] } }, res)
|
|
assert.deepEqual(res.body, { streams: ['news.post'] })
|
|
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
assert.equal(item(surface, 'no.such.stream'), undefined)
|
|
assert.deepEqual([...subRows], [`${USER} news.post`])
|
|
})
|
|
|
|
// ── Acceptance: defaults ───────────────────────────────────────────────────
|
|
|
|
test("a fresh user's modes are the channel defaults — push and email off, in-app on", async () => {
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
const news = item(surface, 'news.post')
|
|
|
|
// **`inapp` is 'instant' from Phase 7**, and it is the only one that is.
|
|
// Settled by the org lead 2026-08-31: the argument for opt-IN was that push
|
|
// wakes a device somebody is holding and email leaves the building, and an
|
|
// inbox item does neither — it is a row on a page the user chose to open. Left
|
|
// 'off' the surface ships dead, because no rule could reach anybody until
|
|
// every user found a toggle for a channel they had never seen deliver
|
|
// anything.
|
|
assert.deepEqual(news.modes, { push: 'off', email: 'off', inapp: 'instant' })
|
|
assert.equal(prefRows.size, 0, 'reading preferences must not write rows')
|
|
|
|
// The acceptance line in ENGAGEMENT.md originally said push defaults
|
|
// 'instant'. It cannot: `notification_subscriptions` is opt-IN, so that would
|
|
// have projected the whole catalog into the legacy GET for every existing
|
|
// user and switched every toggle on in the shipped app. Settled 'off' by the
|
|
// org lead; this assertion is what stops it drifting back.
|
|
const legacy = mockRes()
|
|
await notifCtrl.getSubscriptions({ user: PLAYER }, legacy)
|
|
assert.deepEqual(legacy.body, { streams: [] })
|
|
})
|
|
|
|
test('a mode with no stored row reads as the channel default, not as a hardcoded off', async () => {
|
|
// Prove the default is READ from the registry rather than assumed: re-register
|
|
// `inapp` with a different default and the same fresh user reads it back.
|
|
channels._reset()
|
|
channels.registerDeliveryChannel({
|
|
id: 'inapp', label: 'On the site', carriesContent: true, defaultMode: 'instant', supportsDigest: false,
|
|
})
|
|
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
assert.equal(item(surface, 'news.post').modes.inapp, 'instant')
|
|
})
|
|
|
|
// ── Acceptance: the sparse per-channel PUT ─────────────────────────────────
|
|
|
|
test('a per-channel PUT sets email without touching push', async () => {
|
|
await notifCtrl.putSubscriptions({ user: PLAYER, body: { streams: ['news.post'] } }, mockRes())
|
|
|
|
const res = mockRes()
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'news.post', channel: 'email', mode: 'digest' }] } },
|
|
res,
|
|
)
|
|
|
|
const news = item(res.body, 'news.post')
|
|
assert.equal(news.modes.email, 'digest')
|
|
assert.equal(news.modes.push, 'instant', 'the push mode must survive an email-only write')
|
|
|
|
// …and the legacy endpoint agrees, which is the whole point of the projection.
|
|
const legacy = mockRes()
|
|
await notifCtrl.getSubscriptions({ user: PLAYER }, legacy)
|
|
assert.deepEqual(legacy.body, { streams: ['news.post'] })
|
|
})
|
|
|
|
test('a push write through the channels endpoint fans out to the old table', async () => {
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'instant' }] } },
|
|
mockRes(),
|
|
)
|
|
assert.deepEqual([...subRows], [`${USER} team.forum.post`])
|
|
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'team.forum.post', channel: 'push', mode: 'off' }] } },
|
|
mockRes(),
|
|
)
|
|
assert.deepEqual([...subRows], [])
|
|
|
|
// 'off' is STORED, not deleted: it is a statement the user made, and folding it
|
|
// back into "never said" is only harmless while push's default happens to be
|
|
// off. The two are different the moment that default changes.
|
|
assert.equal(prefRows.get(`${USER} team.forum.post push`), 'off')
|
|
})
|
|
|
|
test('entries the catalog cannot accept are dropped, not refused', async () => {
|
|
const res = mockRes()
|
|
await notifCtrl.putChannelPrefs(
|
|
{
|
|
user: PLAYER,
|
|
body: {
|
|
prefs: [
|
|
{ id: 'no.such.id', channel: 'email', mode: 'instant' },
|
|
{ id: 'news.post', channel: 'carrier.pigeon', mode: 'instant' },
|
|
{ id: 'news.post', channel: 'push', mode: 'digest' }, // push has no digest
|
|
{ id: 'news.post', channel: 'email', mode: 'instant' }, // the one good row
|
|
],
|
|
},
|
|
},
|
|
res,
|
|
)
|
|
|
|
assert.equal(res.statusCode, 200)
|
|
assert.equal(item(res.body, 'news.post').modes.email, 'instant')
|
|
assert.equal(item(res.body, 'news.post').modes.push, 'off')
|
|
assert.equal(prefRows.size, 1, 'only the accepted pair was written')
|
|
})
|
|
|
|
test('the last entry wins when a body names the same pair twice', async () => {
|
|
const res = mockRes()
|
|
await notifCtrl.putChannelPrefs(
|
|
{
|
|
user: PLAYER,
|
|
body: {
|
|
prefs: [
|
|
{ id: 'news.post', channel: 'email', mode: 'instant' },
|
|
{ id: 'news.post', channel: 'email', mode: 'digest' },
|
|
],
|
|
},
|
|
},
|
|
res,
|
|
)
|
|
assert.equal(item(res.body, 'news.post').modes.email, 'digest')
|
|
})
|
|
|
|
// ── The catalog: one namespace, two facets ─────────────────────────────────
|
|
|
|
test('a trigger-only id gets email and in-app, and no push toggle', async () => {
|
|
const api = registries.stage('uo')
|
|
api.registerEventTriggers([{
|
|
id: 'uo.house.idoc_warning',
|
|
label: 'House approaching collapse',
|
|
ceiling: 'owner',
|
|
variables: [{ name: 'house', type: 'string', required: true, example: 'The Silver Anvil' }],
|
|
}])
|
|
registries.apply(api.staged)
|
|
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
const idoc = item(surface, 'uo.house.idoc_warning')
|
|
|
|
assert.ok(idoc, 'a trigger-only id is subscribable')
|
|
assert.deepEqual(idoc.channels, ['email', 'inapp'])
|
|
assert.equal('push' in idoc.modes, false, 'there is nothing registered to push it')
|
|
|
|
// A push entry for it is therefore inapplicable and dropped.
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'uo.house.idoc_warning', channel: 'push', mode: 'instant' }] } },
|
|
mockRes(),
|
|
)
|
|
assert.equal(subRows.size, 0)
|
|
assert.equal(prefRows.size, 0)
|
|
})
|
|
|
|
test('an id that is both a stream and a trigger appears once, with all three channels', async () => {
|
|
// Core's five trigger ids ARE its five stream ids — the same-owner upgrade the
|
|
// one-namespace rule exists for, exercised on every boot.
|
|
const surface = await prefs.getForUser(USER, PLAYER)
|
|
const news = surface.items.filter((i) => i.id === 'news.post')
|
|
|
|
assert.equal(news.length, 1)
|
|
assert.deepEqual(news[0].channels, ['push', 'email', 'inapp'])
|
|
assert.equal(news[0].ceiling, 'authenticated', 'the trigger facet supplies the ceiling')
|
|
})
|
|
|
|
test('a staff-ceilinged trigger is not offered to a player, and is to staff', async () => {
|
|
const api = registries.stage('uo')
|
|
api.registerEventTriggers([{
|
|
id: 'uo.cheat.detected',
|
|
label: 'Cheat detected',
|
|
ceiling: 'staff',
|
|
variables: [{ name: 'character', type: 'string', required: true, example: 'Darrow' }],
|
|
}])
|
|
registries.apply(api.staged)
|
|
|
|
const asPlayer = await prefs.getForUser(USER, PLAYER)
|
|
assert.equal(item(asPlayer, 'uo.cheat.detected'), undefined, 'a player is not told it exists')
|
|
|
|
const asAdmin = await prefs.getForUser(USER, ADMIN)
|
|
assert.ok(item(asAdmin, 'uo.cheat.detected'))
|
|
|
|
// And the filter is a gate, not just a display rule: a player who knows the id
|
|
// still cannot store a preference for it.
|
|
const res = mockRes()
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: PLAYER, body: { prefs: [{ id: 'uo.cheat.detected', channel: 'email', mode: 'instant' }] } },
|
|
res,
|
|
)
|
|
assert.equal(prefRows.size, 0)
|
|
})
|
|
|
|
|
|
// **The Phase 11 ceiling, and the reason `visibleTo` stopped asking about `staff`
|
|
// by name.** Its rule used to be `ceiling !== 'staff'`, which was correct while
|
|
// `staff` was the only role-gated value and would have silently published every
|
|
// admin-ceilinged id to every player the day `admin` arrived. An editor is the
|
|
// viewer that tells the two apart: inside `staff`, outside `admin`.
|
|
test('an admin-ceilinged trigger is hidden from a player AND from an editor', async () => {
|
|
const api = registries.stage('uo')
|
|
api.registerEventTriggers([{
|
|
id: 'uo.audit.staff_action',
|
|
label: 'A staff member acted in game',
|
|
ceiling: 'admin',
|
|
audience: 'admin',
|
|
variables: [{ name: 'action', type: 'string', required: true, example: 'set' }],
|
|
}])
|
|
registries.apply(api.staged)
|
|
|
|
const asPlayer = await prefs.getForUser(USER, PLAYER)
|
|
assert.equal(item(asPlayer, 'uo.audit.staff_action'), undefined, 'a player is not told it exists')
|
|
|
|
// The one an `!== staff` test would have got wrong: an editor IS staff, and a
|
|
// digest of what staff did in game is not for them.
|
|
const asEditor = await prefs.getForUser(USER, EDITOR)
|
|
assert.equal(item(asEditor, 'uo.audit.staff_action'), undefined, 'an editor is not told either')
|
|
|
|
const asAdmin = await prefs.getForUser(USER, ADMIN)
|
|
assert.ok(item(asAdmin, 'uo.audit.staff_action'), 'an admin is')
|
|
|
|
// A gate, not a display rule: an editor who knows the id still cannot store a
|
|
// preference for it.
|
|
await notifCtrl.putChannelPrefs(
|
|
{ user: EDITOR, body: { prefs: [{ id: 'uo.audit.staff_action', channel: 'email', mode: 'instant' }] } },
|
|
mockRes(),
|
|
)
|
|
assert.equal(prefRows.size, 0)
|
|
})
|
|
|
|
// The counterpart, so the generalisation did not quietly hide more than it should.
|
|
test('a staff-ceilinged trigger is still offered to an editor', async () => {
|
|
const api = registries.stage('uo')
|
|
api.registerEventTriggers([{
|
|
id: 'uo.page.new',
|
|
label: 'A player opened a help page',
|
|
ceiling: 'staff',
|
|
audience: 'staff',
|
|
variables: [{ name: 'pageType', type: 'string', required: true, example: 'Stuck' }],
|
|
}])
|
|
registries.apply(api.staged)
|
|
|
|
const asEditor = await prefs.getForUser(USER, EDITOR)
|
|
assert.ok(item(asEditor, 'uo.page.new'), 'an editor is inside the staff ceiling')
|
|
assert.equal(item(await prefs.getForUser(USER, PLAYER), 'uo.page.new'), undefined)
|
|
})
|
|
|
|
// ── The channel registry itself ────────────────────────────────────────────
|
|
|
|
test('the registry refuses a channel that under-declares', async () => {
|
|
channels._reset()
|
|
const ok = { id: 'x', label: 'X', carriesContent: true, defaultMode: 'off', supportsDigest: false }
|
|
|
|
assert.throws(() => channels.registerDeliveryChannel({ ...ok, carriesContent: undefined }), /carriesContent/)
|
|
assert.throws(() => channels.registerDeliveryChannel({ ...ok, supportsDigest: undefined }), /supportsDigest/)
|
|
assert.throws(() => channels.registerDeliveryChannel({ ...ok, defaultMode: 'sometimes' }), /defaultMode/)
|
|
assert.throws(() => channels.registerDeliveryChannel({ ...ok, id: 'Not An Id' }), /invalid id/)
|
|
|
|
// A channel that cannot batch cannot default to batching — the failure this
|
|
// prevents is a stored 'digest' row no delivery path can ever honour.
|
|
assert.throws(
|
|
() => channels.registerDeliveryChannel({ ...ok, defaultMode: 'digest', supportsDigest: false }),
|
|
/supportsDigest/,
|
|
)
|
|
|
|
channels.registerDeliveryChannel(ok)
|
|
assert.throws(() => channels.registerDeliveryChannel(ok), /already registered/)
|
|
})
|
|
|
|
test('push is content-free and instant-only, by declaration', async () => {
|
|
assert.equal(channels.get('push').carriesContent, false)
|
|
assert.deepEqual(channels.modesFor('push'), ['off', 'instant'])
|
|
assert.deepEqual(channels.modesFor('email'), ['off', 'instant', 'digest'])
|
|
})
|
|
|
|
test('an unregistered channel reads as off and accepts nothing', async () => {
|
|
// A stored row can name a channel that is no longer registered (a downgrade).
|
|
// It must read as off, never throw and never be on by accident.
|
|
assert.equal(channels.defaultMode('discord.dm'), 'off')
|
|
assert.deepEqual(channels.modesFor('discord.dm'), [])
|
|
assert.equal(channels.acceptsMode('discord.dm', 'instant'), false)
|
|
})
|