feat: implement stop alias management for Telegram import
- Added TelegramStopAliasService for handling stop aliases in the database. - Created API endpoints for managing stop aliases (list, create, update, delete). - Introduced TelegramStopAliases component in the frontend for user interaction with stop aliases. - Updated TelegramImportService to utilize stop aliases for message processing. - Enhanced telegramCancellationParser to resolve stop names using aliases. - Added migration script to create telegram_stop_aliases table and seed initial data. - Updated documentation to include details about stop alias functionality.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { parseCancellation, matchCancellationRoutes, buildCancellationAlert, dayBounds } = require('../src/services/telegramCancellationParser');
|
||||
const { parseCancellation, resolveCancellationStops, matchCancellationRoutes, buildCancellationAlert, normalizeStopKey, dayBounds } = require('../src/services/telegramCancellationParser');
|
||||
|
||||
const text = `Уважаемые пассажиры!
|
||||
Не будут выполняться рейсы:
|
||||
@@ -87,3 +87,31 @@ test('midnight expiry is an explicit UTC instant, including year boundary', () =
|
||||
});
|
||||
|
||||
module.exports = { carrierText: text };
|
||||
|
||||
test('dictionary selects the actual stop ID and uses its canonical name in concise alerts', () => {
|
||||
const stops = [{ id: 11, name: 'Автовокзал' }, { id: 22, name: 'Зодчего Иоанна' }, { id: 23, name: 'Зодчего Иоанна' }];
|
||||
const aliases = [{ normalized_alias: normalizeStopKey('ЗОДЧЕГО'), stop_id: 22 }];
|
||||
const parsed = resolveCancellationStops(parse(), stops, aliases);
|
||||
const routes = [
|
||||
{ id: 1, route_number: '27', origin_stop_id: 11, origin_stop: 'Автовокзал' },
|
||||
{ id: 2, route_number: '27', origin_stop_id: 22, origin_stop: 'Зодчего Иоанна' },
|
||||
{ id: 3, route_number: '27', origin_stop_id: 23, origin_stop: 'Зодчего Иоанна' }
|
||||
];
|
||||
const matched = matchCancellationRoutes(parsed, routes);
|
||||
assert.deepEqual(matched.plans.map(plan => plan.routeId), [1, 2]);
|
||||
const alert = buildCancellationAlert(matched.plans[1], '2026-10-02', 'https://t.me/avp2Polotsk/1166', '2026-10-02T12:52Z', 1);
|
||||
assert.equal(alert.message, '02.10.2026: не выполняются рейсы:\nЗодчего Иоанна — 15:58, 16:58\nИсточник: https://t.me/avp2Polotsk/1166');
|
||||
assert.doesNotMatch(alert.message, /Уважаемые|С УВАЖЕНИЕМ/);
|
||||
assert.equal(normalizeStopKey(' « ЗОД-ЧЕГО » '), normalizeStopKey('Зодчего'));
|
||||
});
|
||||
|
||||
test('canonical names are taken from the catalog; inactive aliases and ambiguous platforms require review', () => {
|
||||
const parsed = parse(text.replace('ЗОДЧЕГО', 'ЗОДЧЕГО ИОАННА'));
|
||||
const stops = [{ id: 11, name: 'Автовокзал' }, { id: 22, name: 'Зодчего Иоанна' }];
|
||||
assert.equal(resolveCancellationStops(parsed, stops, []).groups[0].departures[1].stop, 'Зодчего Иоанна');
|
||||
const inactive = resolveCancellationStops(parse(), [stops[0]], [{ normalized_alias: 'зодчего', stop_id: 22 }]);
|
||||
assert.match(matchCancellationRoutes(inactive, [{ id: 1, route_number: '27' }]).reasons[0], /отключена/);
|
||||
const ambiguous = resolveCancellationStops(parsed, [...stops, { id: 23, name: 'Зодчего Иоанна' }], []);
|
||||
const result = matchCancellationRoutes(ambiguous, [{ id: 1, route_number: '27', origin_stop: 'Зодчего Иоанна' }, { id: 2, route_number: '27', origin_stop: 'Зодчего Иоанна' }]);
|
||||
assert.equal(result.plans.length, 0);
|
||||
});
|
||||
|
||||
@@ -18,20 +18,23 @@ async function setup() {
|
||||
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 stops(id serial PRIMARY KEY,name text,address text,city_id int REFERENCES cities(id),is_active boolean DEFAULT true);
|
||||
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 stops(name,city_id) VALUES ('Автовокзал',1),('Зодчего Иоанна',1),('Остановка другого города',2);
|
||||
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
|
||||
const aliasesMigration = await fs.readFile(path.join(__dirname, '../migrations/014_telegram_stop_aliases.sql'), 'utf8');
|
||||
await db.exec(aliasesMigration);
|
||||
await db.exec(aliasesMigration);
|
||||
// A session is leased for the whole transaction, just like a pg Pool client.
|
||||
let tail = Promise.resolve();
|
||||
async function lease() {
|
||||
@@ -128,3 +131,76 @@ test('Telegram SQL import rolls back all alerts if a later route insert fails',
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM alerts')).rows[0].n, 1);
|
||||
} finally { await db.close(); }
|
||||
});
|
||||
|
||||
test('stop dictionary validates city and uniqueness, resolves waiting messages and preserves published text', { skip: !modulePath }, async () => {
|
||||
const { db, pool, service, clock } = await setup();
|
||||
try {
|
||||
const dictionary = service.stopAliases;
|
||||
let aliases = await dictionary.list(1);
|
||||
assert.equal(aliases.length, 2, 'migration seeds known abbreviations once');
|
||||
const zodchego = aliases.find(a => a.alias === 'ЗОДЧЕГО');
|
||||
assert.equal(zodchego.stop_name, 'Зодчего Иоанна');
|
||||
await assert.rejects(dictionary.save(1, { alias: ' зод-чего ', stop_id: 2 }), error => error.status === 409);
|
||||
await assert.rejects(dictionary.save(1, { alias: 'Другой город', stop_id: 3 }), error => error.status === 400);
|
||||
await assert.rejects(dictionary.save(1, { alias: '?!', stop_id: 2 }), error => error.status === 400);
|
||||
await assert.rejects(dictionary.remove(zodchego.id, 2), error => error.status === 404);
|
||||
await dictionary.remove(zodchego.id, 1);
|
||||
|
||||
const original = message(1200);
|
||||
assert.equal((await service.processMessage(original, 1)).status, 'review');
|
||||
assert.equal((await service.processMessage(original, 1)).status, 'unchanged');
|
||||
let row = (await service.list(1)).find(m => m.message_id === 1200);
|
||||
assert.match(row.drafts.find(d => d.routeId === null).message, /Автовокзал — 15:43, 16:43/);
|
||||
assert.doesNotMatch(row.drafts[0].message, /Уважаемые|С УВАЖЕНИЕМ/);
|
||||
const saved = await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 2 });
|
||||
assert.equal((await service.processMessage(original, 1)).status, 'published', 'unchanged Telegram post can be resolved after adding an alias');
|
||||
const alerts = (await pool.query('SELECT a.* FROM alerts a JOIN telegram_import_alerts t ON t.alert_id=a.id WHERE t.message_id=$1 ORDER BY route_id', [row.id])).rows;
|
||||
assert.equal(alerts.length, 2);
|
||||
assert.match(alerts[1].message, /Зодчего Иоанна — 15:58, 16:58/);
|
||||
row = (await service.list(1)).find(m => m.message_id === 1200);
|
||||
assert.ok(row.drafts.some(d => d.routeId === 2 && d.message.includes('Зодчего Иоанна')));
|
||||
await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 1 }, saved.id);
|
||||
assert.equal((await service.processMessage(original, 1)).status, 'unchanged');
|
||||
assert.equal((await pool.query('SELECT message FROM alerts WHERE id=$1', [alerts[1].id])).rows[0].message, alerts[1].message);
|
||||
await assert.rejects(dictionary.save(2, { alias: 'Чужое', stop_id: 3 }, saved.id), error => error.status === 404);
|
||||
|
||||
// Inactive mappings never auto-publish, even for a route with one direction.
|
||||
await dictionary.save(1, { alias: 'ЗОДЧЕГО', stop_id: 2 }, saved.id);
|
||||
await pool.query('UPDATE stops SET is_active=false WHERE id=2');
|
||||
assert.equal((await service.processMessage(message(1201), 1)).status, 'review');
|
||||
const inactive = (await service.list(1)).find(m => m.message_id === 1201);
|
||||
assert.match(inactive.reasons[0], /отключена/);
|
||||
await assert.rejects(dictionary.save(1, { alias: 'Новое', stop_id: 2 }), error => error.status === 400);
|
||||
await pool.query('UPDATE stops SET is_active=true WHERE id=2');
|
||||
clock('2026-10-02T21:00:00Z');
|
||||
assert.equal((await service.processMessage(message(1201), 1)).status, 'expired', 'dictionary changes cannot publish yesterday after midnight');
|
||||
assert.equal((await dictionary.list(2)).length, 0);
|
||||
|
||||
// Re-running the migration never picks one of two identically named platforms.
|
||||
await dictionary.remove(saved.id, 1);
|
||||
await pool.query("INSERT INTO stops(name,city_id) VALUES ('Зодчего Иоанна',1)");
|
||||
await db.exec(await fs.readFile(path.join(__dirname, '../migrations/014_telegram_stop_aliases.sql'), 'utf8'));
|
||||
assert.equal((await dictionary.list(1)).some(a => a.alias === 'ЗОДЧЕГО'), false);
|
||||
} finally { await db.close(); }
|
||||
});
|
||||
|
||||
test('reviewed snapshots respect auto-publish configuration changes and midnight without requiring a source edit', { skip: !modulePath }, async () => {
|
||||
const { db, pool, service, clock } = await setup();
|
||||
// This test uses its own configuration so it cannot change other tests.
|
||||
service.config = { ...config, autoPublish: false };
|
||||
try {
|
||||
assert.equal((await service.processMessage(message(1300), 1)).status, 'review');
|
||||
assert.equal((await service.processMessage(message(1300), 1)).status, 'unchanged');
|
||||
service.config.autoPublish = true;
|
||||
assert.equal((await service.processMessage(message(1300), 1)).status, 'published');
|
||||
service.config.autoPublish = false;
|
||||
assert.equal((await service.processMessage(message(1301), 1)).status, 'review');
|
||||
await service.processMessage(message(1302, carrierText.replace('\nС ОСТАНОВКИ ЗОДЧЕГО', '\nПО МАРШРУТУ № 27 С ОСТАНОВКИ ЗОДЧЕГО')), 1);
|
||||
const draft = (await service.list(1)).find(m => m.message_id === 1302).drafts.filter(d => d.routeId === null);
|
||||
assert.equal(draft.length, 1);
|
||||
assert.match(draft[0].message, /Автовокзал — 15:43, 16:43\nЗодчего Иоанна — 15:58, 16:58/);
|
||||
clock('2026-10-02T21:00:00Z');
|
||||
assert.equal((await service.processMessage(message(1301), 1)).status, 'expired');
|
||||
assert.equal((await pool.query('SELECT count(*)::int AS n FROM telegram_import_alerts t JOIN telegram_import_messages m ON m.id=t.message_id WHERE m.message_id=1301')).rows[0].n, 0);
|
||||
} finally { await db.close(); }
|
||||
});
|
||||
|
||||
@@ -15,7 +15,12 @@ test('Telegram admin API rejects anonymous/non-admin access and preserves select
|
||||
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' }; }
|
||||
review: async (...args) => { calls.push(['review', ...args]); return { status: 'dismissed' }; },
|
||||
stopAliases: {
|
||||
list: async (...args) => { calls.push(['aliases', ...args]); return []; },
|
||||
save: async (...args) => { calls.push(['saveAlias', ...args]); return { id: 9 }; },
|
||||
remove: async (...args) => { calls.push(['removeAlias', ...args]); return { id: 9 }; }
|
||||
}
|
||||
};
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -36,5 +41,17 @@ test('Telegram admin API rejects anonymous/non-admin access and preserves select
|
||||
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);
|
||||
for (const method of ['GET', 'POST', 'PUT', 'DELETE']) {
|
||||
const endpoint = `${url}/stop-aliases${['PUT', 'DELETE'].includes(method) ? '/9' : ''}`;
|
||||
assert.equal((await fetch(endpoint, { method, headers: { Authorization: `Bearer ${token('user')}` } })).status, 403);
|
||||
const result = await fetch(endpoint, { method, headers, ...(method === 'POST' || method === 'PUT' ? { body: JSON.stringify({ alias: 'ЗОДЧЕГО', stop_id: 22 }) } : {}) });
|
||||
assert.equal(result.status, 200);
|
||||
}
|
||||
assert.deepEqual(calls.slice(-4), [
|
||||
['aliases', 2], ['saveAlias', 2, { alias: 'ЗОДЧЕГО', stop_id: 22 }],
|
||||
['saveAlias', 2, { alias: 'ЗОДЧЕГО', stop_id: 22 }, 9], ['removeAlias', 9, 2]
|
||||
]);
|
||||
assert.equal((await fetch(`${url}/stop-aliases/0`, { method: 'DELETE', headers })).status, 400);
|
||||
assert.equal((await fetch(`${url}/stop-aliases/999999999999999999`, { method: 'PUT', headers, body: '{}' })).status, 400);
|
||||
} finally { await new Promise(resolve => server.close(resolve)); }
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user