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