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.
This commit is contained in:
403
backend/test/alertCreationService.test.js
Normal file
403
backend/test/alertCreationService.test.js
Normal file
@@ -0,0 +1,403 @@
|
||||
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 })
|
||||
);
|
||||
});
|
||||
243
backend/test/alertNotificationService.test.js
Normal file
243
backend/test/alertNotificationService.test.js
Normal file
@@ -0,0 +1,243 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
|
||||
const {
|
||||
ALERT_TITLE_FALLBACKS,
|
||||
AlertNotificationService,
|
||||
buildAlertCreatedMessage
|
||||
} = require('../src/services/alertNotificationService');
|
||||
const { createFirebaseMessaging, getFirebaseConfig } = require('../src/config/firebase');
|
||||
|
||||
function createLogger() {
|
||||
const errors = [];
|
||||
return {
|
||||
errors,
|
||||
error(message, metadata) {
|
||||
errors.push({ message, metadata });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('sends an exact alert-created notification and string-only data payload', async () => {
|
||||
const messages = [];
|
||||
const service = new AlertNotificationService({
|
||||
enabled: true,
|
||||
topic: 'route_alerts',
|
||||
androidChannelId: 'route_alerts',
|
||||
messaging: {
|
||||
async send(message) {
|
||||
messages.push(message);
|
||||
return 'projects/test/messages/42';
|
||||
}
|
||||
},
|
||||
logger: createLogger()
|
||||
});
|
||||
|
||||
const result = await service.sendAlertCreated({
|
||||
id: 42,
|
||||
route_id: 7,
|
||||
route_number: 12,
|
||||
alert_type: 'delay',
|
||||
title: 'Автобус задерживается',
|
||||
message: 'Задержка около 15 минут',
|
||||
start_time: new Date('2026-07-11T08:30:00.000Z'),
|
||||
end_time: '2026-07-11T09:30:00Z',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
sent: true,
|
||||
messageId: 'projects/test/messages/42'
|
||||
});
|
||||
assert.equal(messages.length, 1);
|
||||
assert.deepEqual(messages[0], {
|
||||
topic: 'route_alerts',
|
||||
notification: {
|
||||
title: 'Автобус задерживается',
|
||||
body: 'Задержка около 15 минут'
|
||||
},
|
||||
data: {
|
||||
event: 'alert_created',
|
||||
alertId: '42',
|
||||
type: 'delay',
|
||||
routeId: '7',
|
||||
routeNumber: '12',
|
||||
startTime: '2026-07-11T08:30:00.000Z',
|
||||
endTime: '2026-07-11T09:30:00.000Z'
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
channelId: 'route_alerts',
|
||||
tag: 'alert_42'
|
||||
}
|
||||
}
|
||||
});
|
||||
assert.ok(Object.values(messages[0].data).every((value) => typeof value === 'string'));
|
||||
});
|
||||
|
||||
test('serializes a null route id and missing optional values as empty strings', () => {
|
||||
const message = buildAlertCreatedMessage({
|
||||
id: 9,
|
||||
route_id: null,
|
||||
route_number: null,
|
||||
alert_type: 'info',
|
||||
title: 'Новость',
|
||||
message: 'Важная информация',
|
||||
start_time: null,
|
||||
end_time: null
|
||||
});
|
||||
|
||||
assert.equal(message.data.routeId, '');
|
||||
assert.equal(message.data.routeNumber, '');
|
||||
assert.equal(message.data.startTime, '');
|
||||
assert.equal(message.data.endTime, '');
|
||||
});
|
||||
|
||||
test('does not initialize messaging or send for an inactive alert', async () => {
|
||||
let messagingInitializations = 0;
|
||||
const service = new AlertNotificationService({
|
||||
enabled: true,
|
||||
getMessaging() {
|
||||
messagingInitializations += 1;
|
||||
return { send: async () => 'unexpected' };
|
||||
},
|
||||
logger: createLogger()
|
||||
});
|
||||
|
||||
const result = await service.sendAlertCreated({ id: 10, is_active: false });
|
||||
|
||||
assert.deepEqual(result, { sent: false, skipped: true, reason: 'inactive' });
|
||||
assert.equal(messagingInitializations, 0);
|
||||
});
|
||||
|
||||
test('disabled mode works without initializing Firebase or credentials', async () => {
|
||||
let messagingInitializations = 0;
|
||||
const service = new AlertNotificationService({
|
||||
enabled: false,
|
||||
getMessaging() {
|
||||
messagingInitializations += 1;
|
||||
throw new Error('credentials should not be read');
|
||||
},
|
||||
logger: createLogger()
|
||||
});
|
||||
|
||||
const result = await service.sendAlertCreated({ id: 11, is_active: true });
|
||||
|
||||
assert.deepEqual(result, { sent: false, skipped: true, reason: 'disabled' });
|
||||
assert.equal(messagingInitializations, 0);
|
||||
});
|
||||
|
||||
test('logs Firebase failures with safe structured metadata and does not throw', async () => {
|
||||
const testLogger = createLogger();
|
||||
const firebaseError = Object.assign(new Error('Permission denied'), {
|
||||
code: 'messaging/authentication-error'
|
||||
});
|
||||
const service = new AlertNotificationService({
|
||||
enabled: true,
|
||||
messaging: {
|
||||
async send() {
|
||||
throw firebaseError;
|
||||
}
|
||||
},
|
||||
logger: testLogger
|
||||
});
|
||||
|
||||
const result = await service.sendAlertCreated({
|
||||
id: 57,
|
||||
alert_type: 'detour',
|
||||
message: 'Объезд',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
sent: false,
|
||||
reason: 'fcm_error',
|
||||
firebaseCode: 'messaging/authentication-error'
|
||||
});
|
||||
assert.deepEqual(testLogger.errors, [{
|
||||
message: 'Failed to send alert-created FCM notification',
|
||||
metadata: {
|
||||
alertId: '57',
|
||||
reason: 'Permission denied',
|
||||
firebaseCode: 'messaging/authentication-error'
|
||||
}
|
||||
}]);
|
||||
});
|
||||
|
||||
test('logs lazy Firebase initialization failures and does not throw', async () => {
|
||||
const testLogger = createLogger();
|
||||
const initializationError = Object.assign(new Error('Application default credentials unavailable'), {
|
||||
code: 'app/invalid-credential'
|
||||
});
|
||||
const service = new AlertNotificationService({
|
||||
enabled: true,
|
||||
getMessaging() {
|
||||
throw initializationError;
|
||||
},
|
||||
logger: testLogger
|
||||
});
|
||||
|
||||
const result = await service.sendAlertCreated({
|
||||
id: 58,
|
||||
alert_type: 'info',
|
||||
message: 'Новость',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
sent: false,
|
||||
reason: 'fcm_error',
|
||||
firebaseCode: 'app/invalid-credential'
|
||||
});
|
||||
assert.deepEqual(testLogger.errors[0].metadata, {
|
||||
alertId: '58',
|
||||
reason: 'Application default credentials unavailable',
|
||||
firebaseCode: 'app/invalid-credential'
|
||||
});
|
||||
});
|
||||
|
||||
test('uses the requested Russian title fallbacks and a route-aware body fallback', () => {
|
||||
for (const [type, expectedTitle] of Object.entries(ALERT_TITLE_FALLBACKS)) {
|
||||
const message = buildAlertCreatedMessage({
|
||||
id: 71,
|
||||
alert_type: type,
|
||||
title: ' ',
|
||||
message: '',
|
||||
route_number: '5'
|
||||
});
|
||||
|
||||
assert.equal(message.notification.title, expectedTitle);
|
||||
assert.equal(message.notification.body, 'Актуальная информация по маршруту №5');
|
||||
}
|
||||
|
||||
const withoutRoute = buildAlertCreatedMessage({
|
||||
id: 72,
|
||||
alert_type: 'info',
|
||||
title: '',
|
||||
message: ''
|
||||
});
|
||||
assert.equal(withoutRoute.notification.body, 'Актуальная информация о движении транспорта');
|
||||
});
|
||||
|
||||
test('reads disabled-by-default Firebase configuration and environment overrides', () => {
|
||||
assert.deepEqual(getFirebaseConfig({}), {
|
||||
enabled: false,
|
||||
topic: 'route_alerts',
|
||||
androidChannelId: 'route_alerts',
|
||||
projectId: undefined
|
||||
});
|
||||
assert.equal(createFirebaseMessaging(getFirebaseConfig({})), null);
|
||||
|
||||
assert.deepEqual(getFirebaseConfig({
|
||||
FCM_ENABLED: 'true',
|
||||
FCM_TOPIC: 'custom_alerts',
|
||||
FCM_ANDROID_CHANNEL_ID: 'custom_channel',
|
||||
FIREBASE_PROJECT_ID: 'ridebus-test'
|
||||
}), {
|
||||
enabled: true,
|
||||
topic: 'custom_alerts',
|
||||
androidChannelId: 'custom_channel',
|
||||
projectId: 'ridebus-test'
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user