Add Telegram cancellation import functionality with tests and documentation
- Implemented Telegram cancellation parsing logic in `telegramCancellationParser.js`. - Created unit tests for cancellation parsing in `telegramCancellationParser.test.js`. - Developed integration tests for Telegram import service in `telegramImport.integration.test.js`. - Added API tests for Telegram import endpoints in `telegramImportApi.test.js`. - Introduced service tests for Telegram import logic in `telegramImportService.test.js`. - Documented the Telegram cancellation import process in `telegram-cancellation-import.md`. - Built a React component for managing Telegram imports in `TelegramImportPanel.jsx`.
This commit is contained in:
@@ -8,6 +8,17 @@ const {
|
||||
} = require('../src/services/alertNotificationService');
|
||||
const { createFirebaseMessaging, getFirebaseConfig } = require('../src/config/firebase');
|
||||
|
||||
test('expires a cancellation at Minsk midnight and does not send a stale push', async () => {
|
||||
const now = new Date('2026-10-02T20:59:00Z');
|
||||
const alert = { id: 1, alert_type: 'cancellation', end_time: '2026-10-02T21:00:00Z' };
|
||||
assert.equal(buildAlertCreatedMessage(alert, { now }).android.ttl, 60_000);
|
||||
let sends = 0;
|
||||
const service = new AlertNotificationService({ enabled: true, now: () => new Date('2026-10-02T21:00:00Z'),
|
||||
getMessaging: async () => { sends++; }, logger: createLogger() });
|
||||
assert.deepEqual(await service.sendAlertCreated(alert), { sent: false, skipped: true, reason: 'expired' });
|
||||
assert.equal(sends, 0);
|
||||
});
|
||||
|
||||
function createLogger() {
|
||||
const errors = [];
|
||||
return {
|
||||
@@ -23,6 +34,7 @@ test('sends an exact data-only alert-created message with string-only data', asy
|
||||
const service = new AlertNotificationService({
|
||||
enabled: true,
|
||||
topic: 'route_alerts',
|
||||
now: () => new Date('2026-07-11T08:30:00Z'),
|
||||
androidChannelId: 'route_alerts',
|
||||
messaging: {
|
||||
async send(message) {
|
||||
@@ -64,7 +76,8 @@ test('sends an exact data-only alert-created message with string-only data', asy
|
||||
body: 'Задержка около 15 минут'
|
||||
},
|
||||
android: {
|
||||
priority: 'high'
|
||||
priority: 'high',
|
||||
ttl: 3_600_000
|
||||
}
|
||||
});
|
||||
assert.equal('notification' in messages[0], false);
|
||||
|
||||
89
backend/test/telegramCancellationParser.test.js
Normal file
89
backend/test/telegramCancellationParser.test.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { parseCancellation, matchCancellationRoutes, buildCancellationAlert, dayBounds } = require('../src/services/telegramCancellationParser');
|
||||
|
||||
const text = `Уважаемые пассажиры!
|
||||
Не будут выполняться рейсы:
|
||||
ПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43.
|
||||
С ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.
|
||||
С УВАЖЕНИЕМ
|
||||
Филиал "Автобусный парк №2 г.Полоцка" ОАО "Витебскоблавтотранс"`;
|
||||
const parse = (value = text, publishedAt = '2026-10-02T12:52:00Z') => parseCancellation({ text: value, publishedAt });
|
||||
|
||||
test('carrier post preserves four individual departures and canonicalizes АВ/ВОКЗАЛ', () => {
|
||||
const result = parse();
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.serviceDate, '2026-10-02');
|
||||
assert.deepEqual(result.groups, [{ routeNumber: '27', departures: [
|
||||
{ stop: 'Автовокзал', times: ['15:43', '16:43'] }, { stop: 'ЗОДЧЕГО', times: ['15:58', '16:58'] }
|
||||
] }]);
|
||||
});
|
||||
|
||||
test('implicit date comes from publication in Minsk, never from import day or edit date', () => {
|
||||
assert.equal(parse(text, '2026-10-02T22:30:00Z').serviceDate, '2026-10-03');
|
||||
});
|
||||
|
||||
test('tomorrow, numeric and Russian dates take precedence over publication day', () => {
|
||||
for (const prefix of ['Завтра\n', '03.10.2026\n', 'На 03.10\n', '3 октября 2026 года\n']) {
|
||||
assert.equal(parse(prefix + text).serviceDate, '2026-10-03', prefix);
|
||||
}
|
||||
assert.equal(parse('Завтра\n' + text, '2026-12-31T17:00:00Z').serviceDate, '2027-01-01');
|
||||
assert.equal(parse('На 01.01\n' + text, '2026-12-31T17:00:00Z').serviceDate, '2027-01-01');
|
||||
});
|
||||
|
||||
test('09.10 in a departure list remains a time', () => {
|
||||
assert.deepEqual(parse(text.replace('15.43', '09.10')).groups[0].departures[0].times, ['09:10', '16:43']);
|
||||
});
|
||||
|
||||
test('ambiguous dates, conditions, invalid times and unspecified departures require review', () => {
|
||||
for (const value of [
|
||||
'Сегодня и завтра\n' + text, 'На 31.02.2026\n' + text,
|
||||
'С 02.10 по 05.10\n' + text, 'По пятницам\n' + text,
|
||||
text.replace('15.43', '25.43'), text.replace('15.43', '15.99'),
|
||||
'Не будут выполняться рейсы по маршруту №27.', text + '\nКроме отправления на 18:00.',
|
||||
'Послезавтра\n' + text
|
||||
]) assert.equal(parse(value).status, 'review', value);
|
||||
});
|
||||
|
||||
test('news is ignored; a picture without text stays on review', () => {
|
||||
assert.equal(parse('С праздником, уважаемые пассажиры!').status, 'ignored');
|
||||
assert.equal(parseCancellation({ text: '', hasMedia: true, publishedAt: '2026-10-02T10:00:00Z' }).status, 'review');
|
||||
});
|
||||
|
||||
test('review retains a recognized future date; attached media cannot be silently auto-published', () => {
|
||||
const result = parse('Завтра\nНе будут выполняться рейсы по маршруту №27.');
|
||||
assert.equal(result.status, 'review');
|
||||
assert.equal(result.serviceDate, '2026-10-03');
|
||||
assert.equal(parseCancellation({ text, hasMedia: true, publishedAt: '2026-10-02T12:00:00Z' }).status, 'review');
|
||||
});
|
||||
|
||||
test('different route numbers in one post are separate groups', () => {
|
||||
const result = parse(text + '\nПО МАРШРУТУ № 5А С ОСТАНОВКИ ЦЕНТР НА 18:00.');
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.deepEqual(result.groups.map(g => g.routeNumber), ['27', '5а']);
|
||||
});
|
||||
|
||||
test('route number with two directions is matched by origin stop, with no guess on missing origin', () => {
|
||||
const routes = [
|
||||
{ id: 1, route_number: '27', origin_stop: 'Автовокзал' },
|
||||
{ id: 2, route_number: '27', origin_stop: 'Зодчего' },
|
||||
{ id: 3, route_number: '5', origin_stop: 'Центр' }
|
||||
];
|
||||
const matched = matchCancellationRoutes(parse(), routes);
|
||||
assert.deepEqual(matched.plans.map(p => p.routeId), [1, 2]);
|
||||
assert.equal(matched.reasons.length, 0);
|
||||
assert.equal(matchCancellationRoutes(parse(), routes.map(r => ({ ...r, origin_stop: '' }))).plans.length, 0);
|
||||
assert.equal(matchCancellationRoutes(parse(), []).reasons.length, 1);
|
||||
});
|
||||
|
||||
test('midnight expiry is an explicit UTC instant, including year boundary', () => {
|
||||
assert.deepEqual(dayBounds('2026-10-02'), { start: '2026-10-01T21:00:00.000Z', end: '2026-10-02T21:00:00.000Z' });
|
||||
assert.equal(dayBounds('2026-12-31').end, '2026-12-31T21:00:00.000Z');
|
||||
const plan = matchCancellationRoutes(parse(), [{ id: 1, route_number: '27' }]).plans[0];
|
||||
const alert = buildCancellationAlert(plan, '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52:00Z', 1);
|
||||
assert.equal(alert.end_time, '2026-10-02T21:00:00.000Z');
|
||||
assert.match(alert.message, /Автовокзал.*15:43, 16:43/);
|
||||
assert.match(alert.title, /отдельных рейсов/);
|
||||
});
|
||||
|
||||
module.exports = { carrierText: text };
|
||||
130
backend/test/telegramImport.integration.test.js
Normal file
130
backend/test/telegramImport.integration.test.js
Normal file
@@ -0,0 +1,130 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const { TelegramImportService } = require('../src/services/telegramImportService');
|
||||
const { CLAIM_DUE_ALERTS_SQL } = require('../src/services/alertSchedulerService');
|
||||
|
||||
// Run against an isolated in-memory PostgreSQL engine, never the working database.
|
||||
// TELEGRAM_TEST_PGLITE_MODULE can point to a temporary npm installation.
|
||||
const modulePath = process.env.TELEGRAM_TEST_PGLITE_MODULE;
|
||||
const carrierText = 'Не будут выполняться рейсы:\nПО МАРШРУТУ № 27 С ОСТАНОВКИ АВ/ВОКЗАЛ НА 15.43,16.43.\nС ОСТАНОВКИ ЗОДЧЕГО НА 15.58,16.58.';
|
||||
const config = { enabled: true, autoPublish: true, apiId: 123, apiHash: 'a'.repeat(32), sessionFile: '/tmp/private/account.session', channel: 'avp2Polotsk', citySlug: 'polotsk', intervalMs: 60_000 };
|
||||
const message = (id = 1166, text = carrierText) => ({ channelId: '1234567', channelUsername: 'avp2Polotsk', messageId: id, publishedAt: '2026-10-02T12:52:00Z', editedAt: null, hasMedia: false, text });
|
||||
|
||||
async function setup() {
|
||||
const { PGlite } = require(modulePath);
|
||||
const db = new PGlite();
|
||||
await db.exec(`CREATE TABLE cities(id serial PRIMARY KEY,slug text,is_active boolean DEFAULT true);
|
||||
CREATE TABLE users(id serial PRIMARY KEY);
|
||||
CREATE TABLE routes(id serial PRIMARY KEY,route_number text,name text,city_id int REFERENCES cities(id),is_active boolean DEFAULT true);
|
||||
CREATE TABLE stops(id serial PRIMARY KEY,name text);
|
||||
CREATE TABLE route_stops(route_id int,stop_id int,sequence int);
|
||||
CREATE TABLE alerts(id serial PRIMARY KEY,route_id int REFERENCES routes(id),alert_type text,title text,message text,
|
||||
city_id int,start_time timestamp,end_time timestamp,is_active boolean DEFAULT true,created_at timestamp DEFAULT now(),
|
||||
notification_sent_at timestamptz,notification_claimed_at timestamptz,notification_attempts int DEFAULT 0);
|
||||
INSERT INTO cities(slug) VALUES ('polotsk'),('novopolotsk'); INSERT INTO users DEFAULT VALUES;
|
||||
INSERT INTO routes(route_number,name,city_id) VALUES ('27','Автовокзал → Зодчего',1),('27','Зодчего → Автовокзал',1),('27','Другой город',2);
|
||||
INSERT INTO stops(name) VALUES ('Автовокзал'),('Зодчего');
|
||||
INSERT INTO route_stops VALUES (1,1,1),(2,2,1);
|
||||
INSERT INTO alerts(route_id,city_id,start_time,end_time,title,message,alert_type) VALUES (1,1,'2026-10-02 12:00','2026-10-02 21:00','Old','Old','info');
|
||||
SET TIME ZONE 'Europe/Minsk';`);
|
||||
const migration = await fs.readFile(path.join(__dirname, '../migrations/013_telegram_alert_import.sql'), 'utf8');
|
||||
await db.exec(migration);
|
||||
await db.exec(migration); // idempotent upgrades
|
||||
// A session is leased for the whole transaction, just like a pg Pool client.
|
||||
let tail = Promise.resolve();
|
||||
async function lease() {
|
||||
const previous = tail;
|
||||
let release;
|
||||
tail = new Promise(resolve => { release = resolve; });
|
||||
await previous;
|
||||
return { query: (sql, args) => db.query(sql, args), release };
|
||||
}
|
||||
const pool = { connect: lease, query: async (sql, args) => {
|
||||
const client = await lease();
|
||||
try { return await client.query(sql, args); } finally { client.release(); }
|
||||
} };
|
||||
let now = new Date('2026-10-02T13:00:00Z');
|
||||
const service = new TelegramImportService({ pool, config, source: { close: async () => {} }, logger: { warn() {}, error() {} }, now: () => now });
|
||||
return { db, pool, service, clock: value => { now = new Date(value); } };
|
||||
}
|
||||
|
||||
test('Telegram SQL lifecycle: atomic import, duplicates, edits, expiry, deletion and city isolation', { skip: !modulePath }, async () => {
|
||||
const { db, pool, service, clock } = await setup();
|
||||
try {
|
||||
assert.equal((await pool.query('SELECT end_time FROM alerts WHERE id=1')).rows[0].end_time.toISOString(), '2026-10-02T21:00:00.000Z');
|
||||
const results = await Promise.all([service.processMessage(message(), 1), service.processMessage(message(), 1)]);
|
||||
assert.deepEqual(results.map(r => r.status), ['published', 'unchanged']);
|
||||
let alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY route_id')).rows;
|
||||
assert.equal(alerts.length, 2);
|
||||
assert.deepEqual(alerts.map(a => a.route_id), [1, 2]);
|
||||
assert.equal(alerts[0].end_time.toISOString(), '2026-10-02T21:00:00.000Z');
|
||||
assert.match(alerts[0].message, /Автовокзал.*15:43, 16:43/);
|
||||
assert.ok(alerts.every(a => a.notification_sent_at === null));
|
||||
|
||||
// Delivered pushes aren't repeated on text edits, while the stored alert changes.
|
||||
await pool.query('UPDATE alerts SET notification_sent_at=now() WHERE notification_pending=true');
|
||||
const edited = { ...message(), text: carrierText.replace('15.43', '15.45'), editedAt: '2026-10-02T13:01:00Z' };
|
||||
assert.equal((await service.processMessage(edited, 1)).status, 'published');
|
||||
alerts = (await pool.query('SELECT * FROM alerts WHERE notification_pending=true ORDER BY route_id')).rows;
|
||||
assert.equal(alerts.length, 2);
|
||||
assert.ok(alerts.every(a => a.notification_sent_at !== null));
|
||||
assert.match(alerts[0].message, /15:45/);
|
||||
assert.equal((await service.processMessage(message(), 1)).status, 'unchanged', 'stale Telegram snapshot cannot undo an edit');
|
||||
|
||||
// Missing text after an edit withdraws the old alerts and requires a review.
|
||||
assert.equal((await service.processMessage({ ...edited, text: 'Движение восстановлено.', editedAt: '2026-10-02T13:02:00Z' }, 1)).status, 'review');
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts WHERE notification_pending=true')).rows[0].n, 0);
|
||||
const row = (await service.list(1))[0];
|
||||
const input = { action: 'publish', content_hash: row.content_hash, service_date: '2026-10-02', route_id: 1, title: 'Отмена рейса', message: 'Автовокзал, 15:45' };
|
||||
await assert.rejects(service.review(row.id, 2, input, 1), /не найдено/);
|
||||
await assert.rejects(service.review(row.id, 1, { ...input, route_id: 3 }, 1), /выбранного города/);
|
||||
await assert.rejects(service.review(row.id, 1, { ...input, content_hash: 'stale' }, 1), /изменилась/);
|
||||
await service.review(row.id, 1, input, 1);
|
||||
await service.review(row.id, 1, { action: 'dismiss', content_hash: row.content_hash }, 1);
|
||||
assert.equal((await service.processMessage({ ...edited, editedAt: '2026-10-02T14:00:00Z' }, 1)).status, 'dismissed');
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts WHERE notification_pending=true')).rows[0].n, 0);
|
||||
|
||||
// Administrator deletion is respected on a subsequent source edit.
|
||||
await service.processMessage(message(1167), 1);
|
||||
const deletedId = (await pool.query('SELECT alert_id FROM telegram_import_alerts WHERE message_id=(SELECT id FROM telegram_import_messages WHERE message_id=1167) AND route_id=1')).rows[0].alert_id;
|
||||
await pool.query('DELETE FROM alerts WHERE id=$1', [deletedId]);
|
||||
await service.processMessage({ ...message(1167), text: carrierText.replace('15.43', '15.46'), editedAt: '2026-10-02T14:01:00Z' }, 1);
|
||||
assert.equal((await pool.query('SELECT alert_id FROM telegram_import_alerts WHERE message_id=(SELECT id FROM telegram_import_messages WHERE message_id=1167) AND route_id=1')).rows[0].alert_id, null);
|
||||
|
||||
// At midnight, late imports and manual publication cannot reactivate yesterday's cancellation.
|
||||
clock('2026-10-02T21:00:00Z');
|
||||
assert.equal((await service.processMessage(message(1168), 1)).status, 'expired');
|
||||
const expired = (await service.list(1)).find(m => m.message_id === 1168);
|
||||
await assert.rejects(service.review(expired.id, 1, { ...input, content_hash: expired.content_hash }, 1), /уже закончился/);
|
||||
assert.equal((await service.list(2)).length, 0);
|
||||
|
||||
// Pending imported alerts are claimed even when start_time is before created_at.
|
||||
await pool.query(`UPDATE alerts SET is_active=true, notification_pending=true, start_time=now()-interval '1 minute',
|
||||
end_time=now()+interval '1 hour', notification_sent_at=NULL WHERE id<>1`);
|
||||
const claimed = await pool.query(CLAIM_DUE_ALERTS_SQL, [50]);
|
||||
assert.ok(claimed.rows.length > 0);
|
||||
assert.ok(claimed.rows.every(a => a.notification_attempts === 1));
|
||||
assert.equal((await pool.query(CLAIM_DUE_ALERTS_SQL, [50])).rows.length, 0);
|
||||
} finally { await db.close(); }
|
||||
});
|
||||
|
||||
test('Telegram SQL import rolls back all alerts if a later route insert fails', { skip: !modulePath }, async () => {
|
||||
const { db, pool, service } = await setup();
|
||||
try {
|
||||
const connect = pool.connect;
|
||||
pool.connect = async () => {
|
||||
const client = await connect();
|
||||
const query = client.query;
|
||||
client.query = (sql, args) => {
|
||||
if (/INSERT INTO alerts/.test(sql) && args[0] === 2) throw new Error('Injected failure');
|
||||
return query(sql, args);
|
||||
};
|
||||
return client;
|
||||
};
|
||||
await assert.rejects(service.processMessage(message(), 1), /Injected failure/);
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_import_messages')).rows[0].n, 0);
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts')).rows[0].n, 1);
|
||||
} finally { await db.close(); }
|
||||
});
|
||||
40
backend/test/telegramImportApi.test.js
Normal file
40
backend/test/telegramImportApi.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
process.env.JWT_SECRET = 'telegram-api-tests-only-secret';
|
||||
process.env.NODE_ENV = 'production';
|
||||
// This router test never connects to a real database.
|
||||
require.cache[require.resolve('../src/config/database')] = { exports: { query: async () => ({ rows: [] }) } };
|
||||
const express = require('express');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { createTelegramImportRouter } = require('../src/routes/telegramImport');
|
||||
|
||||
test('Telegram admin API rejects anonymous/non-admin access and preserves selected-city scope', async () => {
|
||||
const calls = [];
|
||||
const service = {
|
||||
config: { citySlug: 'polotsk' },
|
||||
getStatus: () => ({ enabled: false }),
|
||||
list: async cityId => { calls.push(['list', cityId]); return []; },
|
||||
sync: async () => { calls.push(['sync']); return {}; },
|
||||
review: async (...args) => { calls.push(['review', ...args]); return { status: 'dismissed' }; }
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, res, next) => { req.city = { id: Number(req.get('X-City-Id') || 1), slug: req.get('X-City-Id') === '2' ? 'novopolotsk' : 'polotsk' }; next(); });
|
||||
app.use('/telegram-import', createTelegramImportRouter(service));
|
||||
const server = await new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
|
||||
const url = `http://127.0.0.1:${server.address().port}/telegram-import`;
|
||||
const token = role => jwt.sign({ id: 7, role }, process.env.JWT_SECRET);
|
||||
try {
|
||||
assert.equal((await fetch(url)).status, 401);
|
||||
assert.equal((await fetch(url, { headers: { Authorization: `Bearer ${token('user')}` } })).status, 403);
|
||||
assert.equal(calls.length, 0);
|
||||
const headers = { Authorization: `Bearer ${token('admin')}`, 'X-City-Id': '2', 'Content-Type': 'application/json' };
|
||||
assert.equal((await fetch(url, { headers })).status, 200);
|
||||
assert.deepEqual(calls[0], ['list', 2]);
|
||||
assert.equal((await fetch(`${url}/sync`, { method: 'POST', headers })).status, 409);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal((await fetch(`${url}/4/review`, { method: 'POST', headers, body: JSON.stringify({ action: 'dismiss', content_hash: 'hash' }) })).status, 200);
|
||||
assert.deepEqual(calls[1], ['review', 4, 2, { action: 'dismiss', content_hash: 'hash' }, 7]);
|
||||
assert.equal((await fetch(`${url}/not-an-id/review`, { method: 'POST', headers, body: '{}' })).status, 400);
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
82
backend/test/telegramImportService.test.js
Normal file
82
backend/test/telegramImportService.test.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { TelegramImportService, publicError } = require('../src/services/telegramImportService');
|
||||
const { TelegramSource } = require('../src/services/telegramSource');
|
||||
const { getTelegramConfig } = require('../src/config/telegram');
|
||||
|
||||
const config = { enabled: true, autoPublish: true, apiId: 123, apiHash: 'a'.repeat(32), sessionFile: '/tmp/private/account.session', channel: 'avp2Polotsk', citySlug: 'polotsk', intervalMs: 60_000 };
|
||||
function setup(source, overrides = {}) {
|
||||
return new TelegramImportService({ config: { ...config, ...overrides }, source,
|
||||
pool: { query: async () => ({ rows: [{ id: 1 }] }) }, logger: { warn() {} }, now: () => new Date('2026-10-02T12:00:00Z') });
|
||||
}
|
||||
|
||||
test('disabled or incomplete setup never connects to Telegram and never reveals credentials in status', async () => {
|
||||
let calls = 0;
|
||||
const source = { fetchMessages: async () => { calls++; } };
|
||||
const disabled = setup(source, { enabled: false });
|
||||
await assert.rejects(disabled.sync(), /выключен/);
|
||||
const missing = setup(source, { apiHash: '' });
|
||||
assert.equal(missing.getStatus().connection, 'configuration_required');
|
||||
await assert.rejects(missing.sync(), /Укажите/);
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(JSON.stringify(disabled.getStatus()).includes(config.apiHash), false);
|
||||
assert.equal(JSON.stringify(disabled.getStatus()).includes(config.sessionFile), false);
|
||||
assert.equal(getTelegramConfig({}).enabled, false);
|
||||
});
|
||||
|
||||
test('worker polls a bounded three-day window, processes sequentially, and reports success', async () => {
|
||||
const processed = [];
|
||||
const source = { async fetchMessages(since) {
|
||||
assert.equal(since.toISOString(), '2026-09-29T21:00:00.000Z');
|
||||
return [{ id: 1 }, { id: 2 }];
|
||||
} };
|
||||
const service = setup(source);
|
||||
service.processMessage = async (message, cityId) => { processed.push([message.id, cityId]); return { status: 'published' }; };
|
||||
const counts = await service.sync();
|
||||
assert.equal(counts.published, 2);
|
||||
assert.deepEqual(processed, [[1, 1], [2, 1]]);
|
||||
assert.equal(service.getStatus().connection, 'ready');
|
||||
});
|
||||
|
||||
test('overlapping worker requests do not start two channel readers', async () => {
|
||||
let finish;
|
||||
const service = setup({ fetchMessages: () => new Promise(resolve => { finish = resolve; }) });
|
||||
const running = service.sync();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
await assert.rejects(service.sync(), /уже выполняется/);
|
||||
finish([]);
|
||||
await running;
|
||||
});
|
||||
|
||||
test('FloodWait backs off and only a sanitized error code is exposed or logged', async () => {
|
||||
const logs = [];
|
||||
let attempts = 0;
|
||||
const service = setup({ async fetchMessages() { attempts++; throw Object.assign(new Error('private-session-secret'), { seconds: 120 }); }, async close() {} });
|
||||
service.logger = { warn: (...args) => logs.push(args) };
|
||||
await assert.rejects(service.sync(), /FLOOD_WAIT/);
|
||||
assert.equal(service.getStatus().retryAt, '2026-10-02T12:02:00.000Z');
|
||||
await assert.rejects(service.sync(), /частоту запросов/);
|
||||
assert.equal(attempts, 1);
|
||||
assert.equal(JSON.stringify([logs, service.getStatus()]).includes('private-session-secret'), false);
|
||||
assert.equal(publicError(new Error('some sensitive content')), 'IMPORT_FAILED');
|
||||
});
|
||||
|
||||
test('source stops reading old history and keeps original publication and edit timestamps', async () => {
|
||||
const source = new TelegramSource(config);
|
||||
source.channel = { id: { toString: () => '1234567' }, username: config.channel };
|
||||
source.connect = async () => {};
|
||||
let iterations = 0;
|
||||
source.client = { async *iterMessages() {
|
||||
for (const entry of [
|
||||
{ className: 'Message', id: 7, date: Date.parse('2026-10-02T10:00Z') / 1000, editDate: Date.parse('2026-10-02T11:00Z') / 1000, message: 'Отмена', media: null },
|
||||
{ className: 'MessageService', id: 6, date: Date.parse('2026-10-02T09:00Z') / 1000 },
|
||||
{ className: 'Message', id: 5, date: Date.parse('2026-09-01T10:00Z') / 1000 },
|
||||
{ className: 'Message', id: 4, date: Date.parse('2026-08-01T10:00Z') / 1000 }
|
||||
]) { iterations++; yield entry; }
|
||||
} };
|
||||
const messages = await source.fetchMessages(new Date('2026-10-01T21:00Z'));
|
||||
assert.equal(iterations, 3);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.equal(messages[0].publishedAt, '2026-10-02T10:00:00.000Z');
|
||||
assert.equal(messages[0].editedAt, '2026-10-02T11:00:00.000Z');
|
||||
});
|
||||
Reference in New Issue
Block a user