- Updated FCM alert notifications documentation to include handling of multiple route numbers. - Enhanced Telegram cancellation import logic to support multiple route numbers, ensuring correct notifications are sent for all directions. - Modified frontend components to allow selection of multiple route numbers for alerts, improving user experience. - Implemented backend migration to add `route_numbers` column to alerts and adjusted related database constraints. - Introduced new utility functions for normalizing and validating route numbers in alert processing.
428 lines
12 KiB
JavaScript
428 lines
12 KiB
JavaScript
const assert = require('node:assert/strict');
|
||
const test = require('node:test');
|
||
|
||
const {
|
||
AlertCreationService,
|
||
createAlertRequestHash,
|
||
normalizeAlertInput,
|
||
isAlertDue
|
||
} = require('../src/services/alertCreationService');
|
||
|
||
const ALERT_INPUT = {
|
||
route_id: 7,
|
||
alert_type: 'delay',
|
||
title: 'Задержка автобуса',
|
||
message: 'Автобус задерживается на 15 минут',
|
||
start_time: '2026-07-11T07:00:00.000Z',
|
||
end_time: null,
|
||
is_active: true
|
||
};
|
||
|
||
function createStoredAlert(overrides = {}) {
|
||
return {
|
||
id: 42,
|
||
route_id: 7,
|
||
alert_type: 'delay',
|
||
title: 'Задержка автобуса',
|
||
message: 'Автобус задерживается на 15 минут',
|
||
start_time: new Date('2026-07-11T07:00:00.000Z'),
|
||
end_time: null,
|
||
is_active: true,
|
||
created_at: new Date('2026-07-11T06:55:00.000Z'),
|
||
notification_route_number: '3',
|
||
...overrides
|
||
};
|
||
}
|
||
|
||
function createLogger() {
|
||
const errors = [];
|
||
return {
|
||
errors,
|
||
error(message, metadata) {
|
||
errors.push({ message, metadata });
|
||
}
|
||
};
|
||
}
|
||
|
||
test('successfully saved active alert dispatches one notification after the insert', async () => {
|
||
const order = [];
|
||
const pool = {
|
||
async query() {
|
||
order.push('saved');
|
||
return { rows: [createStoredAlert()] };
|
||
}
|
||
};
|
||
const notifications = [];
|
||
const notificationService = {
|
||
async sendAlertCreated(alert) {
|
||
order.push('notified');
|
||
notifications.push(alert);
|
||
}
|
||
};
|
||
const service = new AlertCreationService({
|
||
pool,
|
||
notificationService,
|
||
logger: createLogger()
|
||
});
|
||
|
||
const result = await service.createAlert(ALERT_INPUT);
|
||
|
||
assert.deepEqual(order, ['saved', 'notified']);
|
||
assert.equal(notifications.length, 1);
|
||
assert.equal(notifications[0].route_number, '3');
|
||
assert.equal('notification_route_number' in result.alert, false);
|
||
assert.equal(result.alert.id, 42);
|
||
assert.equal(result.created, true);
|
||
});
|
||
|
||
test('inactive alert is saved without dispatching a notification', async () => {
|
||
let notificationCalls = 0;
|
||
const service = new AlertCreationService({
|
||
pool: {
|
||
async query() {
|
||
return { rows: [createStoredAlert({ is_active: false })] };
|
||
}
|
||
},
|
||
notificationService: {
|
||
async sendAlertCreated() {
|
||
notificationCalls += 1;
|
||
}
|
||
},
|
||
logger: createLogger()
|
||
});
|
||
|
||
const result = await service.createAlert({ ...ALERT_INPUT, is_active: false });
|
||
|
||
assert.equal(result.alert.is_active, false);
|
||
assert.equal(notificationCalls, 0);
|
||
});
|
||
|
||
test('future alert is saved but does not dispatch before its start time', async () => {
|
||
let notificationCalls = 0;
|
||
const future = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||
const service = new AlertCreationService({
|
||
pool: { async query() { return { rows: [createStoredAlert({ start_time: new Date(future) })] }; } },
|
||
notificationService: { async sendAlertCreated() { notificationCalls += 1; } },
|
||
logger: createLogger()
|
||
});
|
||
|
||
const result = await service.createAlert({ ...ALERT_INPUT, start_time: future });
|
||
|
||
assert.equal(result.created, true);
|
||
assert.equal(notificationCalls, 0);
|
||
assert.equal(isAlertDue(result.alert), false);
|
||
});
|
||
|
||
test('database failure prevents notification dispatch', async () => {
|
||
const databaseError = new Error('database unavailable');
|
||
let notificationCalls = 0;
|
||
const service = new AlertCreationService({
|
||
pool: {
|
||
async query() {
|
||
throw databaseError;
|
||
}
|
||
},
|
||
notificationService: {
|
||
async sendAlertCreated() {
|
||
notificationCalls += 1;
|
||
}
|
||
},
|
||
logger: createLogger()
|
||
});
|
||
|
||
await assert.rejects(() => service.createAlert(ALERT_INPUT), databaseError);
|
||
assert.equal(notificationCalls, 0);
|
||
});
|
||
|
||
test('unexpected notification failure is logged without failing the saved alert', async () => {
|
||
const logger = createLogger();
|
||
const firebaseError = Object.assign(new Error('permission denied'), {
|
||
code: 'messaging/authentication-error'
|
||
});
|
||
const service = new AlertCreationService({
|
||
pool: {
|
||
async query() {
|
||
return { rows: [createStoredAlert()] };
|
||
}
|
||
},
|
||
notificationService: {
|
||
async sendAlertCreated() {
|
||
throw firebaseError;
|
||
}
|
||
},
|
||
logger
|
||
});
|
||
|
||
const result = await service.createAlert(ALERT_INPUT);
|
||
|
||
assert.equal(result.alert.id, 42);
|
||
assert.equal(logger.errors.length, 1);
|
||
assert.deepEqual(logger.errors[0], {
|
||
message: 'Failed to dispatch alert-created notification',
|
||
metadata: {
|
||
alertId: '42',
|
||
reason: 'permission denied',
|
||
firebaseCode: 'messaging/authentication-error'
|
||
}
|
||
});
|
||
});
|
||
|
||
function createIdempotentPool() {
|
||
const requests = new Map();
|
||
const alerts = new Map();
|
||
let nextAlertId = 1;
|
||
|
||
const client = {
|
||
async query(sql, params = []) {
|
||
const normalizedSql = String(sql).replace(/\s+/g, ' ').trim();
|
||
|
||
if (['BEGIN', 'COMMIT', 'ROLLBACK'].includes(normalizedSql)) {
|
||
return { rows: [] };
|
||
}
|
||
|
||
if (normalizedSql.startsWith('INSERT INTO alert_creation_requests')) {
|
||
const [key, requestHash] = params;
|
||
|
||
if (requests.has(key)) {
|
||
return { rows: [] };
|
||
}
|
||
|
||
requests.set(key, { request_hash: requestHash, alert_id: null });
|
||
return { rows: [{ idempotency_key: key }] };
|
||
}
|
||
|
||
if (normalizedSql.startsWith('SELECT request_hash, alert_id')) {
|
||
return { rows: [requests.get(params[0])] };
|
||
}
|
||
|
||
if (normalizedSql.startsWith('WITH inserted AS')) {
|
||
const alert = createStoredAlert({
|
||
id: nextAlertId,
|
||
route_id: params[0],
|
||
alert_type: params[1],
|
||
title: params[2],
|
||
message: params[3],
|
||
start_time: new Date(params[4]),
|
||
end_time: params[5] ? new Date(params[5]) : null,
|
||
is_active: params[6]
|
||
});
|
||
nextAlertId += 1;
|
||
alerts.set(alert.id, alert);
|
||
return { rows: [alert] };
|
||
}
|
||
|
||
if (normalizedSql.startsWith('UPDATE alert_creation_requests')) {
|
||
const [key, alertId, responseBody] = params;
|
||
requests.get(key).alert_id = alertId;
|
||
requests.get(key).response_body = JSON.parse(responseBody);
|
||
return { rows: [] };
|
||
}
|
||
|
||
throw new Error(`Unexpected query in fake pool: ${normalizedSql}`);
|
||
},
|
||
release() {}
|
||
};
|
||
|
||
return {
|
||
requests,
|
||
alerts,
|
||
deleteAlert(alertId) {
|
||
alerts.delete(alertId);
|
||
|
||
for (const request of requests.values()) {
|
||
if (request.alert_id === alertId) {
|
||
request.alert_id = null;
|
||
}
|
||
}
|
||
},
|
||
async connect() {
|
||
return client;
|
||
}
|
||
};
|
||
}
|
||
|
||
test('same Idempotency-Key and payload replays the alert without duplicate notification', async () => {
|
||
const pool = createIdempotentPool();
|
||
let notificationCalls = 0;
|
||
const service = new AlertCreationService({
|
||
pool,
|
||
notificationService: {
|
||
async sendAlertCreated() {
|
||
notificationCalls += 1;
|
||
}
|
||
},
|
||
logger: createLogger()
|
||
});
|
||
|
||
const first = await service.createAlert(ALERT_INPUT, { idempotencyKey: 'alert-request-1' });
|
||
const replay = await service.createAlert(ALERT_INPUT, { idempotencyKey: 'alert-request-1' });
|
||
|
||
assert.equal(first.alert.id, replay.alert.id);
|
||
assert.equal(first.created, true);
|
||
assert.equal(replay.created, false);
|
||
assert.equal(notificationCalls, 1);
|
||
});
|
||
|
||
test('idempotency snapshot prevents a duplicate even after the original alert is deleted', async () => {
|
||
const pool = createIdempotentPool();
|
||
let notificationCalls = 0;
|
||
const service = new AlertCreationService({
|
||
pool,
|
||
notificationService: {
|
||
async sendAlertCreated() {
|
||
notificationCalls += 1;
|
||
}
|
||
},
|
||
logger: createLogger()
|
||
});
|
||
|
||
const first = await service.createAlert(ALERT_INPUT, { idempotencyKey: 'alert-request-deleted' });
|
||
pool.deleteAlert(first.alert.id);
|
||
const replay = await service.createAlert(ALERT_INPUT, {
|
||
idempotencyKey: 'alert-request-deleted'
|
||
});
|
||
|
||
assert.equal(replay.alert.id, first.alert.id);
|
||
assert.equal(replay.created, false);
|
||
assert.equal(notificationCalls, 1);
|
||
});
|
||
|
||
function createConcurrentIdempotentPool() {
|
||
const requests = new Map();
|
||
let alertInserts = 0;
|
||
|
||
function createClient() {
|
||
return {
|
||
async query(sql, params = []) {
|
||
const normalizedSql = String(sql).replace(/\s+/g, ' ').trim();
|
||
|
||
if (normalizedSql === 'BEGIN' || normalizedSql === 'ROLLBACK') {
|
||
return { rows: [] };
|
||
}
|
||
|
||
if (normalizedSql === 'COMMIT') {
|
||
for (const request of requests.values()) {
|
||
if (!request.committed) {
|
||
request.committed = true;
|
||
request.resolveCommitted();
|
||
}
|
||
}
|
||
return { rows: [] };
|
||
}
|
||
|
||
if (normalizedSql.startsWith('INSERT INTO alert_creation_requests')) {
|
||
const [key, requestHash] = params;
|
||
const existing = requests.get(key);
|
||
|
||
if (existing) {
|
||
await existing.committedPromise;
|
||
return { rows: [] };
|
||
}
|
||
|
||
let resolveCommitted;
|
||
const committedPromise = new Promise((resolve) => {
|
||
resolveCommitted = resolve;
|
||
});
|
||
requests.set(key, {
|
||
request_hash: requestHash,
|
||
alert_id: null,
|
||
response_body: null,
|
||
committed: false,
|
||
committedPromise,
|
||
resolveCommitted
|
||
});
|
||
return { rows: [{ idempotency_key: key }] };
|
||
}
|
||
|
||
if (normalizedSql.startsWith('WITH inserted AS')) {
|
||
alertInserts += 1;
|
||
return { rows: [createStoredAlert({ id: 91 })] };
|
||
}
|
||
|
||
if (normalizedSql.startsWith('UPDATE alert_creation_requests')) {
|
||
const [key, alertId, responseBody] = params;
|
||
const request = requests.get(key);
|
||
request.alert_id = alertId;
|
||
request.response_body = JSON.parse(responseBody);
|
||
return { rows: [] };
|
||
}
|
||
|
||
if (normalizedSql.startsWith('SELECT request_hash, alert_id')) {
|
||
return { rows: [requests.get(params[0])] };
|
||
}
|
||
|
||
throw new Error(`Unexpected query in concurrent fake pool: ${normalizedSql}`);
|
||
},
|
||
release() {}
|
||
};
|
||
}
|
||
|
||
return {
|
||
get alertInserts() {
|
||
return alertInserts;
|
||
},
|
||
async connect() {
|
||
return createClient();
|
||
}
|
||
};
|
||
}
|
||
|
||
test('concurrent requests with one Idempotency-Key create and notify only once', async () => {
|
||
const pool = createConcurrentIdempotentPool();
|
||
let notificationCalls = 0;
|
||
const service = new AlertCreationService({
|
||
pool,
|
||
notificationService: {
|
||
async sendAlertCreated() {
|
||
notificationCalls += 1;
|
||
}
|
||
},
|
||
logger: createLogger()
|
||
});
|
||
|
||
const [first, second] = await Promise.all([
|
||
service.createAlert(ALERT_INPUT, { idempotencyKey: 'concurrent-alert-request' }),
|
||
service.createAlert(ALERT_INPUT, { idempotencyKey: 'concurrent-alert-request' })
|
||
]);
|
||
|
||
assert.equal(first.alert.id, second.alert.id);
|
||
assert.equal([first.created, second.created].filter(Boolean).length, 1);
|
||
assert.equal(pool.alertInserts, 1);
|
||
assert.equal(notificationCalls, 1);
|
||
});
|
||
|
||
test('reusing Idempotency-Key with a different payload is rejected', async () => {
|
||
const service = new AlertCreationService({
|
||
pool: createIdempotentPool(),
|
||
notificationService: { async sendAlertCreated() {} },
|
||
logger: createLogger()
|
||
});
|
||
|
||
await service.createAlert(ALERT_INPUT, { idempotencyKey: 'alert-request-2' });
|
||
|
||
await assert.rejects(
|
||
() => service.createAlert(
|
||
{ ...ALERT_INPUT, message: 'Другое сообщение' },
|
||
{ idempotencyKey: 'alert-request-2' }
|
||
),
|
||
(error) => error.status === 409
|
||
);
|
||
});
|
||
|
||
test('alert request hash is stable for the normalized creation payload', () => {
|
||
const normalized = normalizeAlertInput(ALERT_INPUT);
|
||
|
||
assert.equal(createAlertRequestHash(normalized), createAlertRequestHash({ ...normalized }));
|
||
assert.notEqual(
|
||
createAlertRequestHash(normalized),
|
||
createAlertRequestHash({ ...normalized, is_active: false })
|
||
);
|
||
});
|
||
|
||
test('route-number request hashes ignore selection order, spacing and duplicates', () => {
|
||
const a = normalizeAlertInput({ ...ALERT_INPUT, route_id: null, route_numbers: ['27', '2', '5а'] });
|
||
const b = normalizeAlertInput({ ...ALERT_INPUT, route_id: null, route_numbers: [' №5А ', '2', '27', '2'] });
|
||
assert.equal(createAlertRequestHash(a), createAlertRequestHash(b));
|
||
assert.notEqual(createAlertRequestHash(a), createAlertRequestHash({ ...b, route_numbers: ['2', '27'] }));
|
||
});
|