Files
ridebus-admin/backend/test/alertCreationService.test.js
Zakhar Timoshenko ddd67f438b feat: Implement alert creation service with FCM notifications
- Add AlertCreationService to handle alert creation and notifications.
- Implement idempotency key support to prevent duplicate alerts.
- Create alert notification service for sending FCM messages.
- Add tests for alert creation and notification services.
- Document FCM notification flow and alert creation process.
2026-07-11 01:18:18 +03:00

404 lines
11 KiB
JavaScript

const assert = require('node:assert/strict');
const test = require('node:test');
const {
AlertCreationService,
createAlertRequestHash,
normalizeAlertInput
} = 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('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 })
);
});