Files
ridebus-admin/backend/test/alertCreationService.test.js
Zakhar Timoshenko c0d40f9b90 feat: add support for multiple route numbers in alert notifications
- 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.
2026-10-05 12:07:09 +03:00

428 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'] }));
});