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:
@@ -1,4 +1,4 @@
|
||||
FROM node:18-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
10
backend/migrations/007_alert_creation_idempotency.sql
Normal file
10
backend/migrations/007_alert_creation_idempotency.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS alert_creation_requests (
|
||||
idempotency_key VARCHAR(255) PRIMARY KEY,
|
||||
request_hash CHAR(64) NOT NULL,
|
||||
alert_id INTEGER REFERENCES alerts(id) ON DELETE SET NULL,
|
||||
response_body JSONB,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_creation_requests_created_at
|
||||
ON alert_creation_requests(created_at);
|
||||
@@ -26,6 +26,7 @@
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"firebase-admin": "^14.1.0",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^2.0.2",
|
||||
|
||||
65
backend/src/config/firebase.js
Normal file
65
backend/src/config/firebase.js
Normal file
@@ -0,0 +1,65 @@
|
||||
const DEFAULT_FCM_TOPIC = 'route_alerts';
|
||||
const DEFAULT_FCM_ANDROID_CHANNEL_ID = 'route_alerts';
|
||||
|
||||
function parseBoolean(value, fallback = false) {
|
||||
if (value === undefined || value === null || String(value).trim() === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function getFirebaseConfig(env = process.env) {
|
||||
const topic = String(env.FCM_TOPIC || '').trim();
|
||||
const androidChannelId = String(env.FCM_ANDROID_CHANNEL_ID || '').trim();
|
||||
const projectId = String(env.FIREBASE_PROJECT_ID || '').trim();
|
||||
|
||||
return {
|
||||
enabled: parseBoolean(env.FCM_ENABLED, false),
|
||||
topic: topic || DEFAULT_FCM_TOPIC,
|
||||
androidChannelId: androidChannelId || DEFAULT_FCM_ANDROID_CHANNEL_ID,
|
||||
projectId: projectId || undefined
|
||||
};
|
||||
}
|
||||
|
||||
let messagingSingleton;
|
||||
|
||||
function createFirebaseMessaging(config = getFirebaseConfig()) {
|
||||
if (!config.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep Firebase optional for disabled/local environments. These modular
|
||||
// imports are evaluated only when an enabled notification is actually sent.
|
||||
const { applicationDefault, getApps, initializeApp } = require('firebase-admin/app');
|
||||
const { getMessaging } = require('firebase-admin/messaging');
|
||||
|
||||
const existingApp = getApps()[0];
|
||||
const app = existingApp || initializeApp({
|
||||
credential: applicationDefault(),
|
||||
...(config.projectId ? { projectId: config.projectId } : {})
|
||||
});
|
||||
|
||||
return getMessaging(app);
|
||||
}
|
||||
|
||||
function getFirebaseMessaging(config = getFirebaseConfig()) {
|
||||
if (!config.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!messagingSingleton) {
|
||||
messagingSingleton = createFirebaseMessaging(config);
|
||||
}
|
||||
|
||||
return messagingSingleton;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_FCM_TOPIC,
|
||||
DEFAULT_FCM_ANDROID_CHANNEL_ID,
|
||||
parseBoolean,
|
||||
getFirebaseConfig,
|
||||
createFirebaseMessaging,
|
||||
getFirebaseMessaging
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const { getAlertCreationService } = require('../services/alertCreationService');
|
||||
|
||||
// Get all alerts (with optional filters)
|
||||
router.get('/', async (req, res) => {
|
||||
@@ -97,34 +99,44 @@ router.get('/:id', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
function createAlertHandler({ alertCreationService = null } = {}) {
|
||||
return async (req, res) => {
|
||||
try {
|
||||
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!alert_type || !title || !message || !start_time) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const validTypes = ['delay', 'cancellation', 'detour', 'info'];
|
||||
if (!validTypes.includes(alert_type)) {
|
||||
return res.status(400).json({ error: 'Invalid alert_type' });
|
||||
}
|
||||
|
||||
const service = alertCreationService || getAlertCreationService();
|
||||
const result = await service.createAlert(
|
||||
{ route_id, alert_type, title, message, start_time, end_time, is_active },
|
||||
{ idempotencyKey: req.get('Idempotency-Key') }
|
||||
);
|
||||
|
||||
return res.status(201).json(result.alert);
|
||||
} catch (error) {
|
||||
if (error.status) {
|
||||
return res.status(error.status).json({ error: error.message });
|
||||
}
|
||||
|
||||
logger.error('Error creating alert', {
|
||||
reason: error.message,
|
||||
code: error.code || 'unknown'
|
||||
});
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Create new alert
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { route_id, alert_type, title, message, start_time, end_time, is_active } = req.body;
|
||||
|
||||
// Validation
|
||||
if (!alert_type || !title || !message || !start_time) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const validTypes = ['delay', 'cancellation', 'detour', 'info'];
|
||||
if (!validTypes.includes(alert_type)) {
|
||||
return res.status(400).json({ error: 'Invalid alert_type' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[route_id || null, alert_type, title, message, start_time, end_time || null, is_active !== false]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating alert:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
router.post('/', createAlertHandler());
|
||||
|
||||
// Update alert
|
||||
router.put('/:id', async (req, res) => {
|
||||
@@ -206,4 +218,5 @@ router.post('/cleanup', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports = router;
|
||||
module.exports.createAlertHandler = createAlertHandler;
|
||||
|
||||
267
backend/src/services/alertCreationService.js
Normal file
267
backend/src/services/alertCreationService.js
Normal file
@@ -0,0 +1,267 @@
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const INSERT_ALERT_SQL = `
|
||||
WITH inserted AS (
|
||||
INSERT INTO alerts (route_id, alert_type, title, message, start_time, end_time, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
)
|
||||
SELECT inserted.*, routes.route_number AS notification_route_number
|
||||
FROM inserted
|
||||
LEFT JOIN routes ON routes.id = inserted.route_id
|
||||
`;
|
||||
|
||||
function createHttpError(status, message) {
|
||||
const error = new Error(message);
|
||||
error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
function normalizeIdempotencyKey(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = String(value).trim();
|
||||
|
||||
if (!key) {
|
||||
throw createHttpError(400, 'Idempotency-Key must not be empty');
|
||||
}
|
||||
|
||||
if (key.length > 255) {
|
||||
throw createHttpError(400, 'Idempotency-Key must not exceed 255 characters');
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
function normalizeAlertInput(payload) {
|
||||
return {
|
||||
route_id: payload.route_id || null,
|
||||
alert_type: payload.alert_type,
|
||||
title: payload.title,
|
||||
message: payload.message,
|
||||
start_time: payload.start_time,
|
||||
end_time: payload.end_time || null,
|
||||
is_active: payload.is_active !== false
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeHashValue(value) {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function createAlertRequestHash(alert) {
|
||||
const canonicalPayload = {
|
||||
route_id: normalizeHashValue(alert.route_id),
|
||||
alert_type: normalizeHashValue(alert.alert_type),
|
||||
title: normalizeHashValue(alert.title),
|
||||
message: normalizeHashValue(alert.message),
|
||||
start_time: normalizeHashValue(alert.start_time),
|
||||
end_time: normalizeHashValue(alert.end_time),
|
||||
is_active: normalizeHashValue(alert.is_active)
|
||||
};
|
||||
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(JSON.stringify(canonicalPayload))
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function splitNotificationRow(row) {
|
||||
const { notification_route_number: routeNumber, ...alert } = row;
|
||||
|
||||
return {
|
||||
alert,
|
||||
notificationAlert: {
|
||||
...alert,
|
||||
route_number: routeNumber ?? ''
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parseAlertSnapshot(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
class AlertCreationService {
|
||||
constructor({ pool, notificationService, logger }) {
|
||||
this.pool = pool;
|
||||
this.notificationService = notificationService;
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
async createAlert(payload, { idempotencyKey: rawIdempotencyKey } = {}) {
|
||||
const alertInput = normalizeAlertInput(payload);
|
||||
const idempotencyKey = normalizeIdempotencyKey(rawIdempotencyKey);
|
||||
const result = idempotencyKey
|
||||
? await this.createIdempotentAlert(alertInput, idempotencyKey)
|
||||
: await this.insertAlert(this.pool, alertInput);
|
||||
|
||||
if (result.created && result.alert.is_active !== false) {
|
||||
await this.sendNotification(result.notificationAlert);
|
||||
}
|
||||
|
||||
return {
|
||||
alert: result.alert,
|
||||
created: result.created
|
||||
};
|
||||
}
|
||||
|
||||
async insertAlert(queryable, alertInput) {
|
||||
const result = await queryable.query(INSERT_ALERT_SQL, [
|
||||
alertInput.route_id,
|
||||
alertInput.alert_type,
|
||||
alertInput.title,
|
||||
alertInput.message,
|
||||
alertInput.start_time,
|
||||
alertInput.end_time,
|
||||
alertInput.is_active
|
||||
]);
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
if (!row) {
|
||||
throw new Error('Alert insert did not return the created row');
|
||||
}
|
||||
|
||||
return {
|
||||
...splitNotificationRow(row),
|
||||
created: true
|
||||
};
|
||||
}
|
||||
|
||||
async createIdempotentAlert(alertInput, idempotencyKey) {
|
||||
const requestHash = createAlertRequestHash(alertInput);
|
||||
const client = await this.pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const claimResult = await client.query(
|
||||
`INSERT INTO alert_creation_requests (idempotency_key, request_hash)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (idempotency_key) DO NOTHING
|
||||
RETURNING idempotency_key`,
|
||||
[idempotencyKey, requestHash]
|
||||
);
|
||||
|
||||
if (claimResult.rows.length === 0) {
|
||||
const existingRequestResult = await client.query(
|
||||
`SELECT request_hash, alert_id, response_body
|
||||
FROM alert_creation_requests
|
||||
WHERE idempotency_key = $1
|
||||
FOR UPDATE`,
|
||||
[idempotencyKey]
|
||||
);
|
||||
const existingRequest = existingRequestResult.rows[0];
|
||||
|
||||
if (!existingRequest) {
|
||||
throw new Error('Idempotency record disappeared during alert creation');
|
||||
}
|
||||
|
||||
if (existingRequest.request_hash !== requestHash) {
|
||||
throw createHttpError(
|
||||
409,
|
||||
'Idempotency-Key was already used with a different request payload'
|
||||
);
|
||||
}
|
||||
|
||||
const existingAlert = parseAlertSnapshot(existingRequest.response_body);
|
||||
|
||||
if (!existingAlert) {
|
||||
throw new Error('Idempotency record is missing its alert response snapshot');
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
return {
|
||||
alert: existingAlert,
|
||||
notificationAlert: null,
|
||||
created: false
|
||||
};
|
||||
}
|
||||
|
||||
const created = await this.insertAlert(client, alertInput);
|
||||
|
||||
await client.query(
|
||||
`UPDATE alert_creation_requests
|
||||
SET alert_id = $2,
|
||||
response_body = $3::jsonb
|
||||
WHERE idempotency_key = $1`,
|
||||
[idempotencyKey, created.alert.id, JSON.stringify(created.alert)]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.query('ROLLBACK');
|
||||
} catch (rollbackError) {
|
||||
this.logger.error('Failed to roll back alert creation transaction', {
|
||||
reason: rollbackError.message
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async sendNotification(alert) {
|
||||
try {
|
||||
await this.notificationService.sendAlertCreated(alert);
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to dispatch alert-created notification', {
|
||||
alertId: String(alert.id),
|
||||
reason: error.message || 'Unknown Firebase error',
|
||||
firebaseCode: error.code || 'unknown'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let singleton = null;
|
||||
|
||||
function getAlertCreationService() {
|
||||
if (!singleton) {
|
||||
const pool = require('../config/database');
|
||||
const logger = require('../config/logger');
|
||||
const { getAlertNotificationService } = require('./alertNotificationService');
|
||||
|
||||
singleton = new AlertCreationService({
|
||||
pool,
|
||||
logger,
|
||||
notificationService: getAlertNotificationService()
|
||||
});
|
||||
}
|
||||
|
||||
return singleton;
|
||||
}
|
||||
|
||||
function setAlertCreationServiceForTesting(service) {
|
||||
singleton = service;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AlertCreationService,
|
||||
createAlertRequestHash,
|
||||
getAlertCreationService,
|
||||
normalizeAlertInput,
|
||||
normalizeIdempotencyKey,
|
||||
parseAlertSnapshot,
|
||||
setAlertCreationServiceForTesting
|
||||
};
|
||||
238
backend/src/services/alertNotificationService.js
Normal file
238
backend/src/services/alertNotificationService.js
Normal file
@@ -0,0 +1,238 @@
|
||||
const {
|
||||
DEFAULT_FCM_TOPIC,
|
||||
DEFAULT_FCM_ANDROID_CHANNEL_ID,
|
||||
getFirebaseConfig,
|
||||
getFirebaseMessaging
|
||||
} = require('../config/firebase');
|
||||
|
||||
function getDefaultLogger() {
|
||||
return require('../config/logger');
|
||||
}
|
||||
|
||||
const ALERT_TITLE_FALLBACKS = Object.freeze({
|
||||
delay: 'Задержка движения',
|
||||
cancellation: 'Отмена рейса',
|
||||
detour: 'Изменение маршрута',
|
||||
info: 'Информация о маршруте'
|
||||
});
|
||||
|
||||
function firstDefined(...values) {
|
||||
return values.find((value) => value !== undefined && value !== null);
|
||||
}
|
||||
|
||||
function toDataString(value) {
|
||||
return value === undefined || value === null ? '' : String(value);
|
||||
}
|
||||
|
||||
function toIsoString(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function getAlertType(alert = {}) {
|
||||
return toDataString(firstDefined(alert.alert_type, alert.alertType, alert.type));
|
||||
}
|
||||
|
||||
function getAlertId(alert = {}) {
|
||||
return toDataString(firstDefined(alert.id, alert.alert_id, alert.alertId));
|
||||
}
|
||||
|
||||
function getRouteId(alert = {}) {
|
||||
return toDataString(firstDefined(
|
||||
alert.route_id,
|
||||
alert.routeId,
|
||||
alert.route && firstDefined(alert.route.id, alert.route.route_id, alert.route.routeId)
|
||||
));
|
||||
}
|
||||
|
||||
function getRouteNumber(alert = {}) {
|
||||
return toDataString(firstDefined(
|
||||
alert.route_number,
|
||||
alert.routeNumber,
|
||||
alert.route && firstDefined(alert.route.route_number, alert.route.routeNumber, alert.route.number)
|
||||
));
|
||||
}
|
||||
|
||||
function nonBlankString(value) {
|
||||
const normalized = toDataString(value).trim();
|
||||
return normalized || '';
|
||||
}
|
||||
|
||||
function getNotificationTitle(alert = {}) {
|
||||
const title = nonBlankString(alert.title);
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
|
||||
return ALERT_TITLE_FALLBACKS[getAlertType(alert)] || ALERT_TITLE_FALLBACKS.info;
|
||||
}
|
||||
|
||||
function getNotificationBody(alert = {}) {
|
||||
const message = nonBlankString(firstDefined(alert.message, alert.body));
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const routeNumber = nonBlankString(getRouteNumber(alert));
|
||||
return routeNumber
|
||||
? `Актуальная информация по маршруту №${routeNumber}`
|
||||
: 'Актуальная информация о движении транспорта';
|
||||
}
|
||||
|
||||
function isAlertActive(alert = {}) {
|
||||
const isActive = firstDefined(alert.is_active, alert.isActive);
|
||||
return isActive !== false;
|
||||
}
|
||||
|
||||
function buildAlertCreatedMessage(alert, options = {}) {
|
||||
const alertId = getAlertId(alert);
|
||||
const topic = options.topic || DEFAULT_FCM_TOPIC;
|
||||
const channelId = options.androidChannelId || options.channelId || DEFAULT_FCM_ANDROID_CHANNEL_ID;
|
||||
|
||||
return {
|
||||
topic,
|
||||
notification: {
|
||||
title: getNotificationTitle(alert),
|
||||
body: getNotificationBody(alert)
|
||||
},
|
||||
data: {
|
||||
event: 'alert_created',
|
||||
alertId,
|
||||
type: getAlertType(alert),
|
||||
routeId: getRouteId(alert),
|
||||
routeNumber: getRouteNumber(alert),
|
||||
startTime: toIsoString(firstDefined(alert.start_time, alert.startTime)),
|
||||
endTime: toIsoString(firstDefined(alert.end_time, alert.endTime))
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
channelId,
|
||||
tag: `alert_${alertId}`
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getFirebaseErrorCode(error) {
|
||||
return toDataString(firstDefined(
|
||||
error && error.code,
|
||||
error && error.errorInfo && error.errorInfo.code
|
||||
));
|
||||
}
|
||||
|
||||
class AlertNotificationService {
|
||||
constructor(options = {}) {
|
||||
const firebaseConfig = options.firebaseConfig || getFirebaseConfig(options.env);
|
||||
|
||||
this.enabled = options.enabled === undefined ? firebaseConfig.enabled : options.enabled;
|
||||
this.topic = options.topic || firebaseConfig.topic;
|
||||
this.androidChannelId = options.androidChannelId || options.channelId || firebaseConfig.androidChannelId;
|
||||
this.logger = options.logger || getDefaultLogger();
|
||||
this.getMessaging = options.getMessaging || options.messagingFactory || (() => (
|
||||
options.messaging || getFirebaseMessaging({
|
||||
...firebaseConfig,
|
||||
enabled: this.enabled,
|
||||
topic: this.topic,
|
||||
androidChannelId: this.androidChannelId
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
async sendAlertCreated(alert) {
|
||||
const alertId = getAlertId(alert);
|
||||
|
||||
if (!this.enabled) {
|
||||
return { sent: false, skipped: true, reason: 'disabled' };
|
||||
}
|
||||
|
||||
if (!isAlertActive(alert)) {
|
||||
return { sent: false, skipped: true, reason: 'inactive' };
|
||||
}
|
||||
|
||||
try {
|
||||
const messaging = await this.getMessaging();
|
||||
if (!messaging || typeof messaging.send !== 'function') {
|
||||
throw new Error('Firebase Messaging is unavailable');
|
||||
}
|
||||
|
||||
const message = buildAlertCreatedMessage(alert, {
|
||||
topic: this.topic,
|
||||
androidChannelId: this.androidChannelId
|
||||
});
|
||||
const messageId = await messaging.send(message);
|
||||
const normalizedMessageId = toDataString(messageId);
|
||||
|
||||
try {
|
||||
if (this.logger && typeof this.logger.info === 'function') {
|
||||
this.logger.info('Sent alert-created FCM notification', {
|
||||
alertId,
|
||||
messageId: normalizedMessageId,
|
||||
topic: this.topic
|
||||
});
|
||||
}
|
||||
} catch (loggingError) {
|
||||
// A logging transport must not turn a successful push into a request failure.
|
||||
}
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
messageId: normalizedMessageId
|
||||
};
|
||||
} catch (error) {
|
||||
const reason = error && error.message
|
||||
? String(error.message)
|
||||
: 'Unknown Firebase Messaging error';
|
||||
const firebaseCode = getFirebaseErrorCode(error) || 'unknown';
|
||||
|
||||
try {
|
||||
if (this.logger && typeof this.logger.error === 'function') {
|
||||
this.logger.error('Failed to send alert-created FCM notification', {
|
||||
alertId,
|
||||
reason,
|
||||
firebaseCode
|
||||
});
|
||||
}
|
||||
} catch (loggingError) {
|
||||
// A logging transport must not turn a best-effort push into a request failure.
|
||||
}
|
||||
|
||||
return {
|
||||
sent: false,
|
||||
reason: 'fcm_error',
|
||||
firebaseCode
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let alertNotificationServiceSingleton;
|
||||
|
||||
function getAlertNotificationService() {
|
||||
if (!alertNotificationServiceSingleton) {
|
||||
alertNotificationServiceSingleton = new AlertNotificationService();
|
||||
}
|
||||
|
||||
return alertNotificationServiceSingleton;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ALERT_TITLE_FALLBACKS,
|
||||
toDataString,
|
||||
toIsoString,
|
||||
getAlertType,
|
||||
getAlertId,
|
||||
getRouteId,
|
||||
getRouteNumber,
|
||||
getNotificationTitle,
|
||||
getNotificationBody,
|
||||
isAlertActive,
|
||||
buildAlertCreatedMessage,
|
||||
getFirebaseErrorCode,
|
||||
AlertNotificationService,
|
||||
getAlertNotificationService
|
||||
};
|
||||
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