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:
2026-10-03 00:45:41 +03:00
parent b5c6b1973e
commit f7f0b039dd
28 changed files with 1381 additions and 17 deletions

View File

@@ -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);

View 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 };

View 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(); }
});

View 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)); }
});

View 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');
});