node-red-contrib-telegrambot
Version:
Telegram bot nodes for Node-RED
429 lines (381 loc) • 16.6 kB
JavaScript
const helper = require('node-red-node-test-helper');
const { expect } = require('chai');
const telegrambotModule = require('../../telegrambot/99-telegrambot.js');
helper.init(require.resolve('node-red'));
// Wait for a predicate to become true, polling every 10 ms up to maxMs.
function waitFor(predicate, maxMs) {
return new Promise(function (resolve, reject) {
const deadline = Date.now() + (maxMs || 1000);
(function tick() {
if (predicate()) return resolve();
if (Date.now() > deadline) return reject(new Error('waitFor timed out'));
setTimeout(tick, 10);
})();
});
}
describe('bot-node — auto-restart on fatal error (issue #442 / #440)', function () {
this.timeout(5000);
before(function (done) {
helper.startServer(done);
});
after(function (done) {
helper.stopServer(done);
});
afterEach(function () {
helper.unload();
});
function flow() {
return [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
}
it('scheduleRestart is a function on the config node', function (done) {
helper.load(telegrambotModule, flow(), { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
expect(n.scheduleRestart).to.be.a('function');
expect(n.restartCount).to.equal(0);
expect(n.restartTimer).to.equal(null);
done();
} catch (err) {
done(err);
}
});
});
it('scheduleRestart sets restartTimer and increments restartCount (single-flight)', function (done) {
helper.load(telegrambotModule, flow(), { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
n.scheduleRestart('first');
expect(n.restartTimer).to.not.equal(null);
expect(n.restartCount).to.equal(1);
// Second call must be dropped while a restart is queued.
n.scheduleRestart('second');
expect(n.restartCount).to.equal(1);
// Cancel so the test cleanup doesn't trip the actual restart.
clearTimeout(n.restartTimer);
n.restartTimer = null;
done();
} catch (err) {
done(err);
}
});
});
it('surrenders after 8 consecutive failed restarts and logs node.error', function (done) {
helper.load(telegrambotModule, flow(), { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
let errorMsg = null;
n.error = function (m) {
errorMsg = m;
};
n.restartCount = 8; // already at the cap
n.scheduleRestart('boom');
expect(errorMsg).to.match(/gave up restarting after fatal: boom/);
expect(n.restartTimer).to.equal(null);
done();
} catch (err) {
done(err);
}
});
});
it('uses exponential back-off capped at 60 s', function (done) {
helper.load(telegrambotModule, flow(), { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
// count 0 -> 3000 ms, 1 -> 6000, 2 -> 12000, 3 -> 24000, 4 -> 48000, 5 -> 60000 cap
const delays = [];
const origSetTimeout = setTimeout;
global.setTimeout = function (fn, ms) {
delays.push(ms);
// Return a fake timer handle so cleanup doesn't break.
return { fake: true };
};
try {
for (let i = 0; i < 6; i++) {
n.restartTimer = null; // unblock single-flight for the next call
n.scheduleRestart('test');
}
} finally {
global.setTimeout = origSetTimeout;
}
expect(delays).to.deep.equal([3000, 6000, 12000, 24000, 48000, 60000]);
done();
} catch (err) {
global.setTimeout = require('timers').setTimeout;
done(err);
}
});
});
it('close handler clears the pending restart timer', async function () {
await new Promise(function (resolve) {
helper.load(telegrambotModule, flow(), { b1: { token: 'fake' } }, resolve);
});
const n = helper.getNode('b1');
n.scheduleRestart('queued');
expect(n.restartTimer).to.not.equal(null);
await helper.unload();
// After unload, the close handler must have cleared the timer; otherwise
// it'd fire later against a deleted node.
expect(n.restartTimer).to.equal(null);
});
});
describe('bot-node — stable-window restartCount reset (issue #442 retest, V17.4.2)', function () {
this.timeout(5000);
before(function (done) {
helper.startServer(done);
});
after(function (done) {
helper.stopServer(done);
});
afterEach(function () {
helper.unload();
});
it('a fresh error inside the stable window keeps the backoff escalating', function (done) {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
// Simulate the post-restart "looks stable" timer being set (which is what the
// success path of the restartTimer callback does) without actually firing
// the abortBot+create chain.
n.restartCount = 3; // pretend we've already had 3 escalating restarts
n.restartStableTimer = setTimeout(function () {
n.restartStableTimer = null;
n.restartCount = 0;
}, 60000);
// A new error before the 60 s elapses must:
// 1. cancel the stableTimer (so the previous "success" doesn't reset count)
// 2. NOT reset restartCount — the next backoff continues from where we were
n.scheduleRestart('another error');
expect(n.restartStableTimer).to.equal(null);
expect(n.restartCount).to.equal(4); // 3 -> 4, not 0 -> 1
// delay for count=3 going to 4 is 3000 * 2^3 = 24000 ms.
// (We don't assert delay directly here — covered separately below.)
// Cleanup so the actual scheduled restart doesn't fire during teardown.
clearTimeout(n.restartTimer);
n.restartTimer = null;
done();
} catch (err) {
done(err);
}
});
});
it('an error AFTER the stable window has fired resets to the minimum backoff', function (done) {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
n.restartCount = 5;
// Stable window fired and successfully reset:
n.restartStableTimer = null;
n.restartCount = 0;
// New error after the stable window: clean slate.
n.scheduleRestart('much later');
expect(n.restartCount).to.equal(1);
clearTimeout(n.restartTimer);
n.restartTimer = null;
done();
} catch (err) {
done(err);
}
});
});
it('close handler clears the pending stable-window timer too', async function () {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
await new Promise(function (resolve) {
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, resolve);
});
const n = helper.getNode('b1');
n.restartStableTimer = setTimeout(function () {}, 60000);
expect(n.restartStableTimer).to.not.equal(null);
await helper.unload();
expect(n.restartStableTimer).to.equal(null);
});
});
describe('bot-node — fatal-error log suppression while restart is queued (issue #411 retest)', function () {
this.timeout(5000);
before(function (done) {
helper.startServer(done);
});
after(function (done) {
helper.stopServer(done);
});
afterEach(function () {
helper.unload();
});
it('emits one warn for the first error of a burst, then suppresses while the restart is queued', function (done) {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
const warnLines = [];
n.warn = function (m) {
warnLines.push(m);
};
// Simulate three rapid 'error' events as the bot library would emit during
// a network outage. The auto-restart handler is set up by createTelegramBot,
// so we drive it directly via scheduleRestart + the warn-suppression check
// that the new handler does.
function simulateFatalErrorEvent(msg) {
if (!n.restartTimer) {
n.warn('Bot error: ' + msg);
}
n.scheduleRestart('fatal: ' + msg);
}
simulateFatalErrorEvent('ETIMEDOUT 1');
simulateFatalErrorEvent('ETIMEDOUT 2');
simulateFatalErrorEvent('ETIMEDOUT 3');
// Only the first error of the burst logs; the next two see restartTimer
// already set and stay silent (scheduleRestart also dedupes via single-flight).
const botErrorLines = warnLines.filter(function (line) {
return line.indexOf('Bot error:') === 0;
});
expect(botErrorLines).to.have.length(1);
expect(botErrorLines[0]).to.include('ETIMEDOUT 1');
clearTimeout(n.restartTimer);
n.restartTimer = null;
done();
} catch (err) {
done(err);
}
});
});
});
describe('bot-node — request pool rebuild on scheduleRestart (issue #442, V17.4.5)', function () {
this.timeout(5000);
before(function (done) {
helper.startServer(done);
});
after(function (done) {
helper.stopServer(done);
});
afterEach(function () {
helper.unload();
});
it('constructor wires up a per-bot requestPool object', function (done) {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
expect(n.requestPool).to.be.an('object');
expect(n.request.pool).to.equal(n.requestPool);
expect(n.buildRequestOptions).to.be.a('function');
expect(n.destroyRequestPool).to.be.a('function');
done();
} catch (err) {
done(err);
}
});
});
it('buildRequestOptions returns a fresh pool object each call', function (done) {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
const first = n.buildRequestOptions();
const second = n.buildRequestOptions();
expect(first.pool).to.not.equal(second.pool);
expect(n.requestPool).to.equal(second.pool); // tracked is the latest one
done();
} catch (err) {
done(err);
}
});
});
it('destroyRequestPool calls destroy() on every agent and nulls the pool', function (done) {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
const destroyed = [];
n.requestPool['https:'] = {
destroy: function () {
destroyed.push('https');
},
};
n.requestPool['http:'] = {
destroy: function () {
destroyed.push('http');
},
};
// An entry without .destroy must be tolerated, not crash.
n.requestPool['weird'] = { foo: 'bar' };
n.destroyRequestPool();
expect(destroyed.sort()).to.deep.equal(['http', 'https']);
expect(n.requestPool).to.equal(null);
done();
} catch (err) {
done(err);
}
});
});
it('destroyRequestPool with no pool set is a no-op', function (done) {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
n.requestPool = null;
n.destroyRequestPool(); // must not throw
expect(n.requestPool).to.equal(null);
done();
} catch (err) {
done(err);
}
});
});
it('non-SOCKS bot uses keepAlive agentOptions and a pool field', function (done) {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
expect(n.request.agentOptions).to.be.an('object');
expect(n.request.agentOptions.keepAlive).to.equal(true);
expect(n.request.pool).to.be.an('object');
expect(n.request.agentClass).to.equal(undefined);
done();
} catch (err) {
done(err);
}
});
});
it('close handler destroys the request pool', async function () {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
await new Promise(function (resolve) {
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, resolve);
});
const n = helper.getNode('b1');
let destroyed = false;
n.requestPool['https:'] = {
destroy: function () {
destroyed = true;
},
};
await helper.unload();
expect(destroyed).to.equal(true);
expect(n.requestPool).to.equal(null);
});
});
describe('bot-node — polling-restart single-flight guard (issue #442)', function () {
this.timeout(5000);
before(function (done) {
helper.startServer(done);
});
after(function (done) {
helper.stopServer(done);
});
afterEach(function () {
helper.unload();
});
it('exposes pollingRestartTimer as a tracked timer slot', function (done) {
const flow = [{ id: 'b1', type: 'telegram bot', botname: 'b', updatemode: 'sendonly' }];
helper.load(telegrambotModule, flow, { b1: { token: 'fake' } }, function () {
try {
const n = helper.getNode('b1');
// Initial state: no pending polling restart.
expect(n.pollingRestartTimer === null || n.pollingRestartTimer === undefined).to.equal(true);
done();
} catch (err) {
done(err);
}
});
});
});