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'] })); });