diff --git a/package.json b/package.json index 2833a68..a7599f2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "start": "nodemon src/index.ts", "dist": "tsc", - "test": "mocha --require ts-node/register --extension ts --spec './tests/**/*.ts'", + "test": "NODE_ENV=test mocha --require ts-node/register --extension ts --spec './tests/**/*.ts'", "deploy": "docker compose build --no-cache && docker compose up -d --force-recreate" }, "dependencies": { @@ -18,6 +18,7 @@ "lodash": "^4.17.21", "moment": "^2.30.1", "pg": "^8.16.3", + "pino": "^9.5.0", "telegraf": "^4.16.3" }, "devDependencies": { diff --git a/src/index.ts b/src/index.ts index 39e458b..68e2996 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { config } from './config/config' import { startServer } from './server' import PostgreSQL from './services/postgresql' +import logger from './logger' export let database: PostgreSQL @@ -10,7 +11,8 @@ const app = async () => { database = new PostgreSQL(config.database) await database.start() } catch (error) { - console.error(error) + logger.error({ err: error }, 'Application bootstrap failed') + process.exitCode = 1 } } diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..9dda9eb --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,18 @@ +import pino from 'pino' + +const level = + process.env.LOG_LEVEL ?? + (process.env.NODE_ENV === 'test' ? 'silent' : process.env.NODE_ENV === 'production' ? 'info' : 'debug') + +const logger = pino({ + level, + base: { + service: 'mida-sync', + }, + formatters: { + level: (label) => ({ level: label }), + }, +}) + +export default logger + diff --git a/src/models/alert-report.ts b/src/models/alert-report.ts new file mode 100644 index 0000000..6452cb6 --- /dev/null +++ b/src/models/alert-report.ts @@ -0,0 +1,37 @@ +import { database } from '..' + +const tableName = 'alert_reports' + +export interface AlertReport { + id: number + report_number: string + created_on: string + starts_on: string + ends_on: string + emitted_on: string + estofex_sent: boolean + pretemp_sent: boolean + is_critic: boolean +} + +type EditableAlertReport = Omit + +export const getLastAlertReport = async (): Promise => { + const query = `SELECT * FROM ${tableName} ORDER BY id DESC LIMIT 1` + + const reports = await database.query(query) + + if (reports.length === 0) { + throw new Error('No last alert report found') + } + + return reports[0] +} + +export const createAlertReport = async (report: EditableAlertReport): Promise => { + return database.create(tableName, report) +} + +export const updateLastAlertReport = async (report: Partial, id: number): Promise => { + return database.edit(tableName, report, id) +} diff --git a/src/models/last-alert-report.ts b/src/models/last-alert-report.ts deleted file mode 100644 index 66d77f1..0000000 --- a/src/models/last-alert-report.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { database } from '..' - -const tableName = 'django_models_latestreport' - -export interface LastAlertReport { - id: number - report_id: string - is_critic: boolean - estofex_sent: boolean - pretemp_sent: boolean -} - -type EditableLastAlertReport = Omit - -export const getLastAlertReport = async (): Promise => { - const query = `SELECT * FROM ${tableName} ` - - const reports = await database.query(query) - - if (reports.length === 0) { - throw new Error('No last alert report found') - } - - return reports[0] -} - -export const updateLastAlertReport = async (report: Partial): Promise => { - await database.edit(tableName, report, 1) -} diff --git a/src/models/telegram-user.ts b/src/models/telegram-user.ts deleted file mode 100644 index d2010f7..0000000 --- a/src/models/telegram-user.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { database } from '..' -import { getNodeEnv } from '../config/config' - -const tableName = 'django_models_telegramuser' - -export interface TelegramUser { - id: number - chat_id: string - test: boolean - accept_intervention_notifiers: boolean - note: string | null -} - -export const getTelegramUsers = async (allowTest: boolean = true): Promise => { - const filterStatements: string[] = [] - - filterStatements.push('accept_intervention_notifiers = true') - - if (allowTest && getNodeEnv() === 'development') { - filterStatements.push('test = true') - } - - const query = `SELECT * FROM ${tableName} ${filterStatements.length > 0 ? 'WHERE ' + filterStatements.join(' AND ') : ''}` - - return database.query(query) -} diff --git a/src/routes/forecast-reports.ts b/src/routes/forecast-reports.ts index b0de503..63e5690 100644 --- a/src/routes/forecast-reports.ts +++ b/src/routes/forecast-reports.ts @@ -3,7 +3,7 @@ import { checkEstofexReport } from '../utilites/estofex' import { getEstofexImage, getEstofexReport } from '../services/estofex' import { getTomorrowPretempReport } from '../services/pretemp' import { sendPhotoMessage } from '../services/telegram' -import { getLastAlertReport, updateLastAlertReport } from '../models/last-alert-report' +import { getLastAlertReport, updateLastAlertReport } from '../models/alert-report' export const registerForecastReportsRoutes = (fastify) => { fastify.route({ @@ -24,9 +24,12 @@ export const registerForecastReportsRoutes = (fastify) => { await sendPhotoMessage(config.chat_id, tomorrowReport, 'Nuovo report Pretemp disponibile') - await updateLastAlertReport({ - pretemp_sent: true, - }) + await updateLastAlertReport( + { + pretemp_sent: true, + }, + lastAlertReport.id + ) reply.status(204).send(undefined) }, @@ -53,9 +56,12 @@ export const registerForecastReportsRoutes = (fastify) => { await sendPhotoMessage(config.chat_id, estofexImage, 'Nuovo report Estofex disponibile') - await updateLastAlertReport({ - estofex_sent: true, - }) + await updateLastAlertReport( + { + estofex_sent: true, + }, + lastAlertReport.id + ) reply.status(204).send(undefined) }, diff --git a/src/routes/meteo-alerts.ts b/src/routes/meteo-alerts.ts index 66e2a0c..4d3eae8 100644 --- a/src/routes/meteo-alerts.ts +++ b/src/routes/meteo-alerts.ts @@ -1,5 +1,5 @@ import { sendNewTomorrowAlertMessage } from '../utilites/telegram' -import { getLastAlertReport, updateLastAlertReport } from '../models/last-alert-report' +import { createAlertReport, getLastAlertReport } from '../models/alert-report' import { getTomorrowMeteoAlert } from '../services/meteo-alerts' import { parseMeteoAlert } from '../utilites/meteo-alerts' @@ -18,14 +18,20 @@ export const registerMeteoAlertsRoutes = (fastify) => { const lastAlertReport = await getLastAlertReport() - if (lastAlertReport.report_id !== parsedAlert.id) { + if (lastAlertReport.report_number !== parsedAlert.id) { if (parsedAlert.isCritic) { sendNewTomorrowAlertMessage(parsedAlert) } - await updateLastAlertReport({ - report_id: parsedAlert.id, + await createAlertReport({ + report_number: parsedAlert.id, is_critic: parsedAlert.isCritic, + estofex_sent: false, + pretemp_sent: false, + created_on: new Date().toISOString(), + starts_on: parsedAlert.dataInizio, + ends_on: parsedAlert.dataFine, + emitted_on: parsedAlert.dataEmissione, }) } diff --git a/src/server.ts b/src/server.ts index 1d2f370..e02753c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,10 @@ -import Fastify from 'fastify' +import Fastify, { type FastifyServerOptions } from 'fastify' import { registerMeteoAlertsRoutes } from './routes/meteo-alerts' import i18next from 'i18next' import italian from './resources/locales/it.json' import { registerTestMessageRoutes } from './routes/test-message' import { registerForecastReportsRoutes } from './routes/forecast-reports' +import logger from './logger' const translations = { it: { @@ -13,7 +14,7 @@ const translations = { export const startServer = async () => { const fastify = Fastify({ - logger: true, + logger: logger as unknown as FastifyServerOptions['logger'], disableRequestLogging: true, }) @@ -36,4 +37,6 @@ export const startServer = async () => { await fastify.listen({ port: 3000, }) + + logger.info('HTTP server started on port 3000') } diff --git a/src/services/estofex.ts b/src/services/estofex.ts index 287b952..d6d8ec7 100644 --- a/src/services/estofex.ts +++ b/src/services/estofex.ts @@ -1,5 +1,6 @@ import axios from 'axios' import { XMLParser } from 'fast-xml-parser' +import logger from '../logger' export interface EstofexReport { forecast?: Partial<{ @@ -19,13 +20,18 @@ export interface EstofexReport { export const getEstofexReport = async () => { const xmlUrl = 'https://www.estofex.org/cgi-bin/polygon/showforecast.cgi?xml=yes' - const xmlData = await axios.get(xmlUrl).then((response) => response.data) + try { + const xmlData = await axios.get(xmlUrl).then((response) => response.data) - const parser = new XMLParser({ - ignoreAttributes: false, - }) + const parser = new XMLParser({ + ignoreAttributes: false, + }) - return parser.parse(xmlData) as EstofexReport + return parser.parse(xmlData) as EstofexReport + } catch (error) { + logger.error({ err: error }, 'Failed to fetch Estofex report') + throw error + } } export const getEstofexImage = async () => { @@ -33,7 +39,10 @@ export const getEstofexImage = async () => { try { await axios.head(imageUrl) - } catch {} + } catch (error) { + logger.warn({ err: error }, 'Estofex image not available') + throw new Error('Estofex image not available') + } return imageUrl } diff --git a/src/services/meteo-alerts.ts b/src/services/meteo-alerts.ts index c359ed9..95f493a 100644 --- a/src/services/meteo-alerts.ts +++ b/src/services/meteo-alerts.ts @@ -1,5 +1,6 @@ import axios from 'axios' import customMoment from '../custom-components/custom-moment' +import logger from '../logger' export enum MeteoAlertType { green = 'green', @@ -69,11 +70,16 @@ export const getMeteoAlert = async (date?: string): Promise(baseUrl).then((response) => response.data) + try { + const response = await axios.get(baseUrl).then((response) => response.data) - if (Object.keys(response).length === 0) { - return undefined - } + if (Object.keys(response).length === 0) { + return undefined + } - return response as MeteoAlert + return response as MeteoAlert + } catch (error) { + logger.error({ err: error, date }, 'Failed to retrieve meteo alert') + throw error + } } diff --git a/src/services/postgresql.ts b/src/services/postgresql.ts index f6518cf..0e550d3 100644 --- a/src/services/postgresql.ts +++ b/src/services/postgresql.ts @@ -93,6 +93,22 @@ export default class PostgreSQL { } } + public async create(tableName: string, object: Omit) { + const keys = Object.keys(object) + .map((key) => checkAndTransformKey(key)) + .join(', ') + + const values: any[] = Object.values(object) + + const query = `INSERT INTO ${checkAndTransformKey( + tableName + )} (${keys}) VALUES (${values.map((_, i) => `$${i + 1}`).join(', ')}) RETURNING *` + + const rows = await this.query(query, values) + + return rows[0] + } + public async edit(tableName: string, object: Omit, 'id'>, objectId: number) { const keys = Object.keys(object).map((key, index) => `${checkAndTransformKey(key)} = $${index + 1}`) @@ -104,6 +120,12 @@ export default class PostgreSQL { return rows[0] } + + public async delete(tableName: string, itemId: number) { + const query = `DELETE FROM ${checkAndTransformKey(tableName)} WHERE id = $1` + + await this.query(query, [itemId])[0] + } } // Add backtick to sql reserved keywords diff --git a/src/services/pretemp.ts b/src/services/pretemp.ts index 91eb805..2aadfd9 100644 --- a/src/services/pretemp.ts +++ b/src/services/pretemp.ts @@ -2,6 +2,7 @@ import axios from 'axios' import moment from 'moment' import { toFirstLetterUpperCase } from '../utilites/common' import customMoment from '../custom-components/custom-moment' +import logger from '../logger' export const getPretempReport = async (date: moment.Moment) => { const formattedDate = date.format('DD_MM_YYYY') @@ -25,6 +26,10 @@ export const getPretempReport = async (date: moment.Moment) => { image = url } + if (!image) { + logger.warn({ date: formattedDate }, 'Pretemp report unavailable') + } + return image } diff --git a/tests/services/estofex.ts b/tests/services/estofex.ts new file mode 100644 index 0000000..98afe83 --- /dev/null +++ b/tests/services/estofex.ts @@ -0,0 +1,72 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import sinon, { SinonStub } from 'sinon' +import axios from 'axios' +import { XMLParser } from 'fast-xml-parser' +import { getEstofexImage, getEstofexReport } from '../../src/services/estofex' + +describe('tests/services/estofex', () => { + describe('getEstofexReport', () => { + let axiosGetStub: SinonStub + let parserStub: SinonStub + + beforeEach(() => { + axiosGetStub = sinon.stub(axios, 'get') + parserStub = sinon.stub(XMLParser.prototype, 'parse') + }) + + afterEach(() => { + axiosGetStub.restore() + parserStub.restore() + }) + + it('fetches the XML and returns the parsed report', async () => { + const xmlPayload = '' + const parsedReport = { forecast: {} } + + axiosGetStub.resolves({ data: xmlPayload }) + parserStub.returns(parsedReport) + + const result = await getEstofexReport() + + expect(result).to.equal(parsedReport) + expect(axiosGetStub.calledOnceWithExactly('https://www.estofex.org/cgi-bin/polygon/showforecast.cgi?xml=yes')).to.equal(true) + expect(parserStub.calledOnceWithExactly(xmlPayload)).to.equal(true) + }) + }) + + describe('getEstofexImage', () => { + let axiosHeadStub: SinonStub + + beforeEach(() => { + axiosHeadStub = sinon.stub(axios, 'head') + }) + + afterEach(() => { + axiosHeadStub.restore() + }) + + it('returns the image url when HEAD succeeds', async () => { + axiosHeadStub.resolves() + + const result = await getEstofexImage() + + expect(result).to.equal('https://www.estofex.org/forecasts/tempmap/.png') + expect(axiosHeadStub.calledOnceWithExactly('https://www.estofex.org/forecasts/tempmap/.png')).to.equal(true) + }) + + it('throws when the image is not available', async () => { + axiosHeadStub.rejects(new Error('unavailable')) + + try { + await getEstofexImage() + expect.fail('Expected getEstofexImage to throw') + } catch (error) { + expect((error as Error).message).to.equal('Estofex image not available') + } + + expect(axiosHeadStub.calledOnceWithExactly('https://www.estofex.org/forecasts/tempmap/.png')).to.equal(true) + }) + }) +}) + diff --git a/tests/services/meteo-alerts.ts b/tests/services/meteo-alerts.ts new file mode 100644 index 0000000..111ff75 --- /dev/null +++ b/tests/services/meteo-alerts.ts @@ -0,0 +1,54 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import sinon, { SinonStub } from 'sinon' +import axios from 'axios' +import { getMeteoAlert, MeteoAlert } from '../../src/services/meteo-alerts' + +describe('tests/services/meteo-alerts', () => { + describe('getMeteoAlert', () => { + let axiosGetStub: SinonStub + const baseUrl = + 'https://allertameteo.regione.emilia-romagna.it/datiTempoReale-prevPiog-portlet/get-stato-allerta' + + beforeEach(() => { + axiosGetStub = sinon.stub(axios, 'get') + }) + + afterEach(() => { + axiosGetStub.restore() + }) + + it('returns undefined when the API response is empty', async () => { + axiosGetStub.resolves({ data: {} }) + + const result = await getMeteoAlert() + + expect(result).to.equal(undefined) + expect(axiosGetStub.calledOnceWithExactly(baseUrl)).to.equal(true) + }) + + it('returns the response when it contains data', async () => { + const meteoAlert = { any: 'value' } as unknown as MeteoAlert + + axiosGetStub.resolves({ data: meteoAlert }) + + const result = await getMeteoAlert() + + expect(result).to.equal(meteoAlert) + expect(axiosGetStub.calledOnceWithExactly(baseUrl)).to.equal(true) + }) + + it('appends the date query parameter when provided', async () => { + const date = '2024-05-10 12:00' + const meteoAlert = { any: 'value' } as unknown as MeteoAlert + + axiosGetStub.resolves({ data: meteoAlert }) + + const result = await getMeteoAlert(date) + + expect(result).to.equal(meteoAlert) + expect(axiosGetStub.calledOnceWithExactly(`${baseUrl}?data=${date}`)).to.equal(true) + }) + }) +}) + diff --git a/tests/services/pretemp.ts b/tests/services/pretemp.ts new file mode 100644 index 0000000..bc840f7 --- /dev/null +++ b/tests/services/pretemp.ts @@ -0,0 +1,118 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import sinon, { SinonFakeTimers, SinonStub } from 'sinon' +import moment from 'moment' +import axios from 'axios' +import { getPretempReport, getTomorrowPretempReport } from '../../src/services/pretemp' +import customMoment from '../../src/custom-components/custom-moment' +import { toFirstLetterUpperCase } from '../../src/utilites/common' + +describe('tests/services/pretemp', () => { + describe('getPretempReport', () => { + let axiosHeadStub: SinonStub + + beforeEach(() => { + axiosHeadStub = sinon.stub(axios, 'head') + }) + + afterEach(() => { + axiosHeadStub.restore() + }) + + const getExpectedUrls = (date: moment.Moment) => { + const formattedDate = date.format('DD_MM_YYYY') + const monthLower = date.format('MMMM').toLowerCase() + const monthCapitalized = toFirstLetterUpperCase(monthLower) + + const base = `https://pretemp.altervista.org/archivio/${date.year()}` + + return [ + `${base}/${monthLower}/cartine/${formattedDate}.png`, + `${base}/${monthCapitalized}/cartine/${formattedDate}.png`, + ] + } + + it('returns undefined when no URL responds', async () => { + const date = moment.utc('2024-05-10T00:00:00Z') + const [lowerUrl, upperUrl] = getExpectedUrls(date) + + axiosHeadStub.onFirstCall().rejects(new Error('not found')) + axiosHeadStub.onSecondCall().rejects(new Error('not found')) + + const result = await getPretempReport(date) + + expect(result).to.equal(undefined) + expect(axiosHeadStub.calledTwice).to.equal(true) + expect(axiosHeadStub.firstCall.firstArg).to.equal(lowerUrl) + expect(axiosHeadStub.secondCall.firstArg).to.equal(upperUrl) + }) + + it('returns the last successful URL when both succeed', async () => { + const date = moment.utc('2024-05-10T00:00:00Z') + const [lowerUrl, upperUrl] = getExpectedUrls(date) + + axiosHeadStub.onFirstCall().resolves() + axiosHeadStub.onSecondCall().resolves() + + const result = await getPretempReport(date) + + expect(result).to.equal(upperUrl) + expect(axiosHeadStub.calledTwice).to.equal(true) + expect(axiosHeadStub.firstCall.firstArg).to.equal(lowerUrl) + expect(axiosHeadStub.secondCall.firstArg).to.equal(upperUrl) + }) + + it('returns the first successful URL when the second fails', async () => { + const date = moment.utc('2024-05-10T00:00:00Z') + const [lowerUrl, upperUrl] = getExpectedUrls(date) + + axiosHeadStub.onFirstCall().resolves() + axiosHeadStub.onSecondCall().rejects(new Error('not found')) + + const result = await getPretempReport(date) + + expect(result).to.equal(lowerUrl) + expect(axiosHeadStub.calledTwice).to.equal(true) + expect(axiosHeadStub.firstCall.firstArg).to.equal(lowerUrl) + expect(axiosHeadStub.secondCall.firstArg).to.equal(upperUrl) + }) + }) + + describe('getTomorrowPretempReport', () => { + let axiosHeadStub: SinonStub + let clock: SinonFakeTimers + + beforeEach(() => { + axiosHeadStub = sinon.stub(axios, 'head') + clock = sinon.useFakeTimers({ + now: Date.UTC(2024, 4, 9, 12, 0, 0), + }) + }) + + afterEach(() => { + axiosHeadStub.restore() + clock.restore() + }) + + it('uses tomorrow date and returns the first available URL', async () => { + const tomorrow = customMoment().add(1, 'day') + const formattedDate = tomorrow.format('DD_MM_YYYY') + const monthLower = tomorrow.format('MMMM').toLowerCase() + const monthCapitalized = toFirstLetterUpperCase(monthLower) + const base = `https://pretemp.altervista.org/archivio/${tomorrow.year()}` + const lowerUrl = `${base}/${monthLower}/cartine/${formattedDate}.png` + const upperUrl = `${base}/${monthCapitalized}/cartine/${formattedDate}.png` + + axiosHeadStub.onFirstCall().rejects(new Error('not found')) + axiosHeadStub.onSecondCall().resolves() + + const result = await getTomorrowPretempReport() + + expect(result).to.equal(upperUrl) + expect(axiosHeadStub.calledTwice).to.equal(true) + expect(axiosHeadStub.firstCall.firstArg).to.equal(lowerUrl) + expect(axiosHeadStub.secondCall.firstArg).to.equal(upperUrl) + }) + }) +}) + diff --git a/tests/utilities/common.ts b/tests/utilities/common.ts index 52874ff..ee32c26 100644 --- a/tests/utilities/common.ts +++ b/tests/utilities/common.ts @@ -1,34 +1,36 @@ import { expect } from 'chai' import { toFirstLetterUpperCase } from '../../src/utilites/common' -describe('toFirstLetterUpperCase', () => { - it('capitalizes the first letter of a lowercase word', () => { - expect(toFirstLetterUpperCase('hello')).to.equal('Hello') - }) +describe('tests/utilities/common.ts', () => { + describe('toFirstLetterUpperCase', () => { + it('capitalizes the first letter of a lowercase word', () => { + expect(toFirstLetterUpperCase('hello')).to.equal('Hello') + }) - it('returns the same string if first letter is already uppercase', () => { - expect(toFirstLetterUpperCase('Hello')).to.equal('Hello') - }) + it('returns the same string if first letter is already uppercase', () => { + expect(toFirstLetterUpperCase('Hello')).to.equal('Hello') + }) - it('returns empty string unchanged', () => { - expect(toFirstLetterUpperCase('')).to.equal('') - }) + it('returns empty string unchanged', () => { + expect(toFirstLetterUpperCase('')).to.equal('') + }) - it('capitalizes a single-letter string', () => { - expect(toFirstLetterUpperCase('a')).to.equal('A') - }) + it('capitalizes a single-letter string', () => { + expect(toFirstLetterUpperCase('a')).to.equal('A') + }) - it('does not change strings that start with a non-letter character', () => { - expect(toFirstLetterUpperCase('1abc')).to.equal('1abc') - expect(toFirstLetterUpperCase('!bang')).to.equal('!bang') - }) + it('does not change strings that start with a non-letter character', () => { + expect(toFirstLetterUpperCase('1abc')).to.equal('1abc') + expect(toFirstLetterUpperCase('!bang')).to.equal('!bang') + }) - it('capitalizes unicode first letters', () => { - expect(toFirstLetterUpperCase('éclair')).to.equal('Éclair') - }) + it('capitalizes unicode first letters', () => { + expect(toFirstLetterUpperCase('éclair')).to.equal('Éclair') + }) - it('only changes the first character and leaves the rest intact', () => { - expect(toFirstLetterUpperCase('hELLO')).to.equal('HELLO') - expect(toFirstLetterUpperCase('hello world')).to.equal('Hello world') + it('only changes the first character and leaves the rest intact', () => { + expect(toFirstLetterUpperCase('hELLO')).to.equal('HELLO') + expect(toFirstLetterUpperCase('hello world')).to.equal('Hello world') + }) }) }) diff --git a/tests/utilities/estofex.ts b/tests/utilities/estofex.ts new file mode 100644 index 0000000..0d13f29 --- /dev/null +++ b/tests/utilities/estofex.ts @@ -0,0 +1,108 @@ +import { expect } from 'chai' +import { afterEach, beforeEach, describe, it } from 'mocha' +import sinon, { SinonFakeTimers } from 'sinon' +import { checkEstofexReport } from '../../src/utilites/estofex' +import { EstofexReport } from '../../src/services/estofex' + +describe('tests/utilities/estofex.ts', () => { + describe('checkEstofexReport', () => { + let clock: SinonFakeTimers + + const baseTimestamp = Date.UTC(2024, 4, 10, 0, 0, 0) // 10 maggio 2024 00:00:00 UTC + const oneDayMs = 24 * 60 * 60 * 1000 + const twelveHoursMs = 12 * 60 * 60 * 1000 + + beforeEach(() => { + clock = sinon.useFakeTimers(baseTimestamp) + }) + + afterEach(() => { + clock.restore() + }) + + it('returns false when forecast is missing', () => { + const report: EstofexReport = {} + + expect(checkEstofexReport(report)).to.equal(false) + }) + + it('returns false when start_time or expiry_time is missing', () => { + const withoutStart: EstofexReport = { + forecast: { + expiry_time: { + '@_value': String(baseTimestamp + oneDayMs), + }, + }, + } + + const withoutExpiry: EstofexReport = { + forecast: { + start_time: { + '@_value': String(baseTimestamp), + }, + }, + } + + expect(checkEstofexReport(withoutStart)).to.equal(false) + expect(checkEstofexReport(withoutExpiry)).to.equal(false) + }) + + it("returns false when '@_value' is missing in timestamps", () => { + const report: EstofexReport = { + forecast: { + start_time: {}, + expiry_time: { + '@_value': String(baseTimestamp + oneDayMs), + }, + }, + } + + expect(checkEstofexReport(report)).to.equal(false) + }) + + it('returns true when the report covers the current day plus one', () => { + const report: EstofexReport = { + forecast: { + start_time: { + '@_value': String(baseTimestamp + twelveHoursMs), + }, + expiry_time: { + '@_value': String(baseTimestamp + oneDayMs + twelveHoursMs), + }, + }, + } + + expect(checkEstofexReport(report)).to.equal(true) + }) + + it('returns false when the report starts after tomorrow', () => { + const report: EstofexReport = { + forecast: { + start_time: { + '@_value': String(baseTimestamp + oneDayMs + twelveHoursMs), + }, + expiry_time: { + '@_value': String(baseTimestamp + 3 * oneDayMs), + }, + }, + } + + expect(checkEstofexReport(report)).to.equal(false) + }) + + it('returns false when the report ends before tomorrow', () => { + const report: EstofexReport = { + forecast: { + start_time: { + '@_value': String(baseTimestamp), + }, + expiry_time: { + '@_value': String(baseTimestamp + twelveHoursMs), + }, + }, + } + + expect(checkEstofexReport(report)).to.equal(false) + }) + }) +}) diff --git a/tests/utilities/meteo-alerts.ts b/tests/utilities/meteo-alerts.ts index 19c5d7b..503e8ff 100644 --- a/tests/utilities/meteo-alerts.ts +++ b/tests/utilities/meteo-alerts.ts @@ -3,77 +3,79 @@ import { describe, it } from 'mocha' import { parseMeteoAlert } from '../../src/utilites/meteo-alerts' import { alertZones, MeteoAlertType } from '../../src/services/meteo-alerts' -describe('parseMeteoAlert', () => { - it('throws if the requested zone is not present in the alert', () => { - const zone = 'non_existent_zone' - const alert: any = { - link: '/alerts/allerta_123_45.pdf', - title: 'Test alert', - // no zone property - } +describe('tests/utilities/meteo-alerts.ts', () => { + describe('parseMeteoAlert', () => { + it('throws if the requested zone is not present in the alert', () => { + const zone = 'non_existent_zone' + const alert: any = { + link: '/alerts/allerta_123_45.pdf', + title: 'Test alert', + // no zone property + } - expect(() => parseMeteoAlert(alert, zone as any)).to.throw(`Zone ${zone} not found in alert data`) - }) + expect(() => parseMeteoAlert(alert, zone as any)).to.throw(`Zone ${zone} not found in alert data`) + }) - it('throws if alert.link does not contain a filename', () => { - const zone = alertZones[0] - const alert: any = { - link: '/path/with/trailing/slash/', - title: 'Test alert', - [zone]: { - ghiaccio_pioggia_gela: null, - }, - } + it('throws if alert.link does not contain a filename', () => { + const zone = alertZones[0] + const alert: any = { + link: '/path/with/trailing/slash/', + title: 'Test alert', + [zone]: { + ghiaccio_pioggia_gela: null, + }, + } - expect(() => parseMeteoAlert(alert, zone)).to.throw(`Invalid link format: ${alert.link}`) - }) + expect(() => parseMeteoAlert(alert, zone)).to.throw(`Invalid link format: ${alert.link}`) + }) - it('parses id, updates link, computes isCritic and criticZoneData and omits zone keys from root', () => { - const zone = alertZones[0] - // Construct zone data using the known keys from the utility's correctColors mapping. - const zoneData = { - ghiaccio_pioggia_gela: null, // allowed / removed from criticZoneData - idraulica: MeteoAlertType.yellow, // allowed by correctColors but is not in colorsToRemove -> should appear in criticZoneData - idrogeologica: null, - mareggiate: null, - neve: MeteoAlertType.yellow, // NOT allowed by correctColors.neve -> makes isCritic true and included in criticZoneData - stato_mare: null, - temperature_estreme: MeteoAlertType.green, - temporali: MeteoAlertType.green, - vento: MeteoAlertType.red, // NOT allowed -> included in criticZoneData - } as any + it('parses id, updates link, computes isCritic and criticZoneData and omits zone keys from root', () => { + const zone = alertZones[0] + // Construct zone data using the known keys from the utility's correctColors mapping. + const zoneData = { + ghiaccio_pioggia_gela: null, // allowed / removed from criticZoneData + idraulica: MeteoAlertType.yellow, // allowed by correctColors but is not in colorsToRemove -> should appear in criticZoneData + idrogeologica: null, + mareggiate: null, + neve: MeteoAlertType.yellow, // NOT allowed by correctColors.neve -> makes isCritic true and included in criticZoneData + stato_mare: null, + temperature_estreme: MeteoAlertType.green, + temporali: MeteoAlertType.green, + vento: MeteoAlertType.red, // NOT allowed -> included in criticZoneData + } as any - const originalLink = '/alerts/allerta_123_45.pdf' - const alert: any = { - link: originalLink, - title: 'Sample alert title', - otherProp: 42, - [zone]: zoneData, - } + const originalLink = '/alerts/allerta_123_45.pdf' + const alert: any = { + link: originalLink, + title: 'Sample alert title', + otherProp: 42, + [zone]: zoneData, + } - const parsed = parseMeteoAlert(alert, zone) + const parsed = parseMeteoAlert(alert, zone) - // id: 'allerta_123_45' -> remove .pdf -> 'allerta_123_45' -> replace first '_' -> 'allerta/123_45' -> remove 'allerta' -> '/123_45' - expect(parsed.id).to.equal('/123_45') + // id: 'allerta_123_45' -> remove .pdf -> 'allerta_123_45' -> replace first '_' -> 'allerta/123_45' -> remove 'allerta' -> '/123_45' + expect(parsed.id).to.equal('/123_45') - // link must be prefixed with the base URL - expect(parsed.link).to.equal(`https://allertameteo.regione.emilia-romagna.it${originalLink}`) + // link must be prefixed with the base URL + expect(parsed.link).to.equal(`https://allertameteo.regione.emilia-romagna.it${originalLink}`) - // zoneData should be preserved on the returned object - expect(parsed.zoneData).to.deep.equal(zoneData) + // zoneData should be preserved on the returned object + expect(parsed.zoneData).to.deep.equal(zoneData) - // isCritic should be true because 'neve' and 'vento' use types not allowed by correctColors - expect(parsed.isCritic).to.equal(true) + // isCritic should be true because 'neve' and 'vento' use types not allowed by correctColors + expect(parsed.isCritic).to.equal(true) - // criticZoneData should include only keys whose value is not in colorsToRemove ([null, green]) - // From our zoneData that means idraulica (yellow), neve (yellow), vento (red) - expect(parsed.criticZoneData).to.deep.equal({ - idraulica: MeteoAlertType.yellow, - neve: MeteoAlertType.yellow, - vento: MeteoAlertType.red, - }) + // criticZoneData should include only keys whose value is not in colorsToRemove ([null, green]) + // From our zoneData that means idraulica (yellow), neve (yellow), vento (red) + expect(parsed.criticZoneData).to.deep.equal({ + idraulica: MeteoAlertType.yellow, + neve: MeteoAlertType.yellow, + vento: MeteoAlertType.red, + }) - // The top-level returned object should have omitted the original zone key (it is provided separately as zoneData) - expect((parsed as any)[zone]).to.equal(undefined) + // The top-level returned object should have omitted the original zone key (it is provided separately as zoneData) + expect((parsed as any)[zone]).to.equal(undefined) + }) }) })