- 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`.
131 lines
9.1 KiB
JavaScript
131 lines
9.1 KiB
JavaScript
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(); }
|
||
});
|