-
Create the test file at test/<feature>.test.js (kebab-case-ish; existing files use camelCase like notifBatchMerge.test.js — match the neighbouring style). The npm test glob is test/*.test.js, so it must end in .test.js and live directly under test/.
-
Header boilerplate — every test file starts with:
const { describe, it, beforeEach, after } = require('node:test');
const assert = require('node:assert/strict');
process.env.NOTIF_TRACE_LOG = '0';
Only import the node:test symbols you actually use. Add before/afterEach only when needed.
-
Pure-function tests come first, before any mock setup. Group them with describe(name, () => { it(case, () => { ... }) }). Pure tests should require the function directly from its module — no env vars, no mocks. Example pattern from test/notifBatchMerge.test.js:
const { groupTrackableByDedup, canBatchMergeGroup } = require('../server/queue/notificationConsumer');
describe('groupTrackableByDedup', () => {
it('groups items by dedup_key preserving order', () => {
const groups = groupTrackableByDedup([
{ env: { extra: { dedup_key: 'a' } } },
{ env: { extra: { dedup_key: 'b' } } }
]);
assert.equal(groups.size, 2);
});
});
Verify: pure tests do not call _setInternalsForTest and have no beforeEach.
-
For integration tests that touch the notification consumer, define three mock factories at module scope (copy verbatim from test/notifBatchMerge.test.js lines 22–104):
function makeRedisMock() {
const hashes = new Map();
const lists = new Map();
return {
_hashes: hashes,
_lists: lists,
async hget(key, field) {
const h = hashes.get(key);
return h ? (h[field] || null) : null;
},
async hset(key, field, value) {
const h = hashes.get(key) || {};
h[field] = value;
hashes.set(key, h);
return 1;
},
async incr() { return 1; },
async get() { return null; },
async expire() { return 1; },
async lrem(key, count, value) {
const arr = lists.get(key) || [];
const idx = arr.indexOf(value);
if (idx >= 0) arr.splice(idx, 1);
lists.set(key, arr);
return idx >= 0 ? 1 : 0;
},
pipeline() {
const ops = [];
const self = this;
const chain = {
hset(key, field, value) { ops.push({ op: 'hset', key, field, value }); return chain; },
expire() { return chain; },
zadd() { return chain; },
zremrangebyscore() { return chain; },
async exec() {
for (const o of ops) {
if (o.op === 'hset') {
const h = self._hashes.get(o.key) || {};
h[o.field] = o.value;
self._hashes.set(o.key, h);
}
}
return [];
}
};
return chain;
}
};
}
Expose _hashes and _lists so assertions can read them directly. Verify: redisMock._hashes is a Map and redisMock.pipeline() returns a chainable object with exec().
-
Bot Redis mock (the convref store) returns JSON-stringified convrefs from convref:* keys:
function makeBotRedisMock(convrefs = {}) {
return {
async get(key) {
const m = key.match(/^convref:(.+)$/);
if (!m) return null;
const v = convrefs[m[1]];
return v == null ? null : JSON.stringify(v);
},
async incr() { return 1; }
};
}
Pass { 'int-dev-private': { conversation: { id: '19:...' }, serviceUrl: 'https://smba...' } } to seed convrefs. Pass {} to force the constructed-convref fallback path.
-
Adapter mock records every continueConversation call and the activities sent/updated within its callback:
function makeAdapterMock() {
const calls = [];
return {
_calls: calls,
async continueConversation(conversationRef, callback) {
const record = { conversationRef, sentActivities: [], updatedActivities: [] };
calls.push(record);
const proactiveContext = {
async sendActivity(activity) {
record.sentActivities.push(activity);
return { id: 'new-activity-' + calls.length };
},
async updateActivity(activity) {
record.updatedActivities.push(activity);
return null;
}
};
await callback(proactiveContext);
}
};
}
To simulate edit failure, reassign adapterMock.continueConversation in the specific test with a version that throws on updateActivity (see test/notifBatchMerge.test.js line 314–327).
-
Wire mocks via _setInternalsForTest inside describe():
let originals;
let redisMock, botRedisMock, adapterMock;
beforeEach(() => {
redisMock = makeRedisMock();
botRedisMock = makeBotRedisMock({});
adapterMock = makeAdapterMock();
redisMock._lists.set('notif:processing', []);
originals = _setInternalsForTest({
redis: redisMock,
redisBot: botRedisMock,
adapter: adapterMock
});
});
after(() => {
_setInternalsForTest(originals || {});
});
_setInternalsForTest is destructured from the consumer: const { _setInternalsForTest, handleTrackableBatch } = require('../server/queue/notificationConsumer');. The keys it accepts are redis, redisBot, adapter. It returns the prior internals so they can be restored — capture them.
-
Seed state via redisMock._hashes / _lists directly, not via the public hset (faster, exact). To pre-populate a recent activity:
const key = `notif:recent:${ ROOM }`;
redisMock._hashes.set(key, { [DEDUP]: JSON.stringify({ activityId: 'act-1', ts: Date.now() - 1000, }) });
To make ackOne() succeed, push the raw envelope strings into notif:processing:
for (const it of group) redisMock._lists.get('notif:processing').push(it.raw);
-
Assert against adapterMock._calls — never against internal consumer state. The standard assertions are:
assert.equal(adapterMock._calls.length, 1, 'exactly one adapter call');
assert.equal(adapterMock._calls[0].sentActivities.length, 1);
assert.equal(adapterMock._calls[0].updatedActivities.length, 0);
assert.equal(adapterMock._calls[0].updatedActivities[0].id, 'act-1');
Also assert the stats object passed into the handler reflects the right outcome:
const stats = { sent: 0, edited: 0, coalesced: 0, fallback: 0, dead: 0, expired: 0, redirected: 0 };
await handleTrackableBatch(ROOM, group, stats);
assert.equal(stats.edited, 1);
Read persisted state back from redisMock._hashes and JSON.parse it for value assertions.
-
Use helper factories for envelopes at the top of the file (after the mocks), one per event_type. Example from test/notificationConsumer.test.js:
function checkRunEnv(name, status, conclusion = '', message = CHECK_RUN_MSG, htmlUrl = 'https://example/cr') {
return {
type: 'msg',
message,
extra: {
event_type: 'check_run',
_commit_sha: '4fd48e4',
dedup_key: 'github:commit:4fd48e4',
data: { check_run: { name, status, conclusion, html_url: htmlUrl } }
}
};
}
Keep these pure (no closures over mocks) so they are reusable across describe blocks.
-
Run the test suite with npm test. To run a single file: node --test test/<file>.test.js. To run with --test-only filtering, mark with it.only(...). Verify before claiming success: the final line should show # pass <N> and # fail 0.
-
Lint the test file with npm run lint before committing. ESLint flat config enforces 4-space indent, semicolons, single quotes, and template-curly-spacing: always — write ${ var } not ${var}.