diff --git a/docker/.env b/docker/.env
index 7a8df33..a013eee 100644
--- a/docker/.env
+++ b/docker/.env
@@ -52,6 +52,9 @@ CPX_DB_PASSWORD=cx
CPX_DB_URL=jdbc:postgresql://capxmldb:5432/cx
CPX_DB_USERNAME=cx
CPX_DB_CONNECTION_STRING=postgres://${CPX_DB_USERNAME}:${CPX_DB_PASSWORD}@capxmldb/${CPX_DB_NAME}
+CPX_REDIS_HOST=cap-xml-redis
+CPX_REDIS_PORT=6379
+CPX_REDIS_TLS=false
PGADMIN_DEFAULT_PASSWORD=pgadmin
POSTGRES_PASSWORD=postgres
LIQUIBASE_COMMAND_CHANGELOG_FILE=./changelog/db.changelog-master.xml
diff --git a/docker/infrastructure.yml b/docker/infrastructure.yml
index e3110f4..81a9b03 100644
--- a/docker/infrastructure.yml
+++ b/docker/infrastructure.yml
@@ -39,6 +39,21 @@ services:
interval: 10s
timeout: 5s
retries: 5
+ cap-xml-redis:
+ container_name: cap-xml-redis
+ image: redis:8.4-alpine
+ ports:
+ - "6379:6379"
+ deploy:
+ restart_policy:
+ condition: on-failure
+ networks:
+ ls:
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
volumes:
capxmlpgdata:
external: true
diff --git a/docker/scripts/register-lambda-functions.sh b/docker/scripts/register-lambda-functions.sh
index faf5ab5..7acde00 100755
--- a/docker/scripts/register-lambda-functions.sh
+++ b/docker/scripts/register-lambda-functions.sh
@@ -13,7 +13,10 @@ cpx_db_password=$(echo CPX_DB_PASSWORD=$CPX_DB_PASSWORD)
cpx_db_name=$(echo CPX_DB_NAME=$CPX_DB_NAME)
cpx_db_host=$(echo CPX_DB_HOST=$CPX_DB_HOST)
cpx_agw_url=$(echo CPX_AGW_URL=$deployed_cpx_agw_url)
-set -- $cpx_db_username $cpx_db_password $cpx_db_name $cpx_db_host $cpx_agw_url
+cpx_redis_host=$(echo CPX_REDIS_HOST=$CPX_REDIS_HOST)
+cpx_redis_port=$(echo CPX_REDIS_PORT=$CPX_REDIS_PORT)
+cpx_redis_tls=$(echo CPX_REDIS_TLS=$CPX_REDIS_TLS)
+set -- $cpx_db_username $cpx_db_password $cpx_db_name $cpx_db_host $cpx_agw_url $cpx_redis_host $cpx_redis_port $cpx_redis_tls
custom_environment_variables=$(printf '%s,' "$@" | sed 's/,*$//g')
# Iterate over each file in lambda_functions_dir
diff --git a/lib/functions/processMessage.js b/lib/functions/processMessage.js
index 37ba1b4..ee04749 100644
--- a/lib/functions/processMessage.js
+++ b/lib/functions/processMessage.js
@@ -14,6 +14,7 @@ const Message = require('../models/message')
const EA_WHO = '2.49.0.0.826.1'
const CODE = 'MCP:v2.0'
const severityV2Mapping = require('../models/v2MessageMapping')
+const redis = require('../helpers/redis')
module.exports.processMessage = async (event) => {
try {
@@ -56,8 +57,9 @@ module.exports.processMessage = async (event) => {
throw new Error(JSON.stringify(errors))
}
- // store the message in database
- await service.putMessage(message.putQuery(message, messageV2))
+ const { message: redisMessage, query: dbQuery } = message.putQuery(message, messageV2)
+ // store the message in database and redis/elasticache
+ await Promise.all([service.putMessage(dbQuery), redis.set(redisMessage.identifier, redisMessage)])
console.log(`Finished processing CAP message: ${message.identifier} for ${message.fwisCode}`)
return {
diff --git a/lib/helpers/message.js b/lib/helpers/message.js
index 9b2fe64..e832357 100644
--- a/lib/helpers/message.js
+++ b/lib/helpers/message.js
@@ -6,6 +6,7 @@ const { validateXML } = require('xmllint-wasm')
const fs = require('node:fs')
const path = require('node:path')
const xsdSchema = fs.readFileSync(path.join(__dirname, '..', 'schemas', 'CAP-v1.2.xsd'), 'utf8')
+const redis = require('../helpers/redis')
module.exports.getMessage = async (event, v2) => {
const { error } = eventSchema.validate(event)
@@ -14,15 +15,25 @@ module.exports.getMessage = async (event, v2) => {
throw error
}
- const ret = await service.getMessage(event.pathParameters.id)
-
- if (!ret?.rows || !Array.isArray(ret.rows) || ret.rows.length < 1 || !ret.rows[0].getmessage) {
- console.log('No message found for ' + event.pathParameters.id)
- throw new Error('No message found')
+ // Fetch message from redis, else get from postgres
+ let body
+ const key = event.pathParameters.id
+ const cachedMessage = await redis.get(key)
+
+ if (cachedMessage) {
+ body = v2 ? cachedMessage.alert_v2 : cachedMessage.alert
+ } else {
+ const ret = await service.getMessage(key)
+ if (!ret?.rows || !Array.isArray(ret.rows) || ret.rows.length < 1 || !ret.rows[0].getmessage) {
+ console.log('No message found for ' + key)
+ throw new Error('No message found')
+ }
+ const message = ret.rows[0].getmessage
+ body = v2 ? message.alert_v2 : message.alert
+ // Cache the message in redis
+ await redis.set(key, message)
}
- const body = v2 ? ret.rows[0].getmessage.alert_v2 : ret.rows[0].getmessage.alert
-
const validationResult = await validateXML({
xml: [{
fileName: 'message.xml',
diff --git a/lib/helpers/redis.js b/lib/helpers/redis.js
new file mode 100644
index 0000000..906afcb
--- /dev/null
+++ b/lib/helpers/redis.js
@@ -0,0 +1,46 @@
+'use strict'
+
+const Redis = require('ioredis')
+let client
+
+const getClient = () => {
+ if (!client || client.status === 'end' || client.status === 'close') {
+ client = new Redis({
+ host: process.env.CPX_REDIS_HOST,
+ port: process.env.CPX_REDIS_PORT,
+ tls: process.env.CPX_REDIS_TLS === 'true' ? { checkServerIdentity: () => undefined } : undefined,
+ maxRetriesPerRequest: 3,
+ connectTimeout: 10000
+ })
+
+ client.on('error', (error) => {
+ console.error('Redis connection error:', error)
+ })
+
+ client.on('connect', () => {
+ console.log('Redis connected successfully')
+ })
+ }
+ return client
+}
+
+module.exports = {
+ get: async (key) => {
+ const redisClient = getClient()
+ const value = await redisClient.get(key)
+ if (value === null) {
+ return null
+ }
+ try {
+ return JSON.parse(value)
+ } catch (error) {
+ console.error(`Failed to parse Redis value for key ${key}:`, error)
+ return value
+ }
+ },
+ set: async (key, value) => {
+ const redisClient = getClient()
+ const serializedValue = typeof value === 'object' ? JSON.stringify(value) : value
+ return redisClient.set(key, serializedValue)
+ }
+}
diff --git a/lib/models/message.js b/lib/models/message.js
index 5408909..f3e2b71 100644
--- a/lib/models/message.js
+++ b/lib/models/message.js
@@ -195,7 +195,7 @@ class Message {
references_v2: messageV2.references,
alert_v2: messageV2.toString()
}
- return messages.insert(message).toQuery()
+ return { message, query: messages.insert(message).toQuery() }
}
}
diff --git a/package-lock.json b/package-lock.json
index 441dd30..5172a43 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,7 @@
"@aws-sdk/client-sns": "3.932.0",
"@xmldom/xmldom": "0.8.11",
"feed": "5.1.0",
+ "ioredis": "^5.9.2",
"joi": "18.0.1",
"moment": "2.30.1",
"pg": "8.16.3",
@@ -1547,6 +1548,12 @@
"url": "https://github.com/sponsors/nzakas"
}
},
+ "node_modules/@ioredis/commands": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
+ "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
+ "license": "MIT"
+ },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2805,6 +2812,15 @@
"node": ">=8"
}
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2908,7 +2924,6 @@
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
- "dev": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -2961,6 +2976,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/diff": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz",
@@ -4315,6 +4339,30 @@
"node": ">= 0.4"
}
},
+ "node_modules/ioredis": {
+ "version": "5.9.2",
+ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.2.tgz",
+ "integrity": "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@ioredis/commands": "1.5.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=12.22.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/ioredis"
+ }
+ },
"node_modules/is-array-buffer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz",
@@ -4885,6 +4933,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.get": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@@ -4893,6 +4947,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5004,8 +5064,7 @@
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/natural-compare": {
"version": "1.4.0",
@@ -5656,6 +5715,27 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz",
@@ -6056,6 +6136,12 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
"node_modules/standard-engine": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/standard-engine/-/standard-engine-15.1.0.tgz",
diff --git a/package.json b/package.json
index 8adb999..6abcde2 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@aws-sdk/client-sns": "3.932.0",
"@xmldom/xmldom": "0.8.11",
"feed": "5.1.0",
+ "ioredis": "5.9.2",
"joi": "18.0.1",
"moment": "2.30.1",
"pg": "8.16.3",
diff --git a/readme.md b/readme.md
index 460dc91..1d732cf 100644
--- a/readme.md
+++ b/readme.md
@@ -17,6 +17,9 @@ This project provides CAP XML services through the use of AWS Lambda.
| CPX_DB_NAME | Database name | yes | | |
| CPX_DB_HOST | Database host | yes | | |
| CPX_AGW_URL | API Gateway URL | yes | | |
+| CPX_REDIS_HOST | Redis/Elasticache host| yes | | |
+| CPX_REDIS_PORT | Redis/Elasticache port| yes | | |
+| CPX_REDIS_TLS | Redis/Elasticache tls | yes | | |
## Prerequisites
diff --git a/test/lib/functions/processMessage.js b/test/lib/functions/processMessage.js
index e8aec78..170cf52 100644
--- a/test/lib/functions/processMessage.js
+++ b/test/lib/functions/processMessage.js
@@ -10,6 +10,7 @@ const xml2js = require('xml2js')
const processMessage = require('../../../lib/functions/processMessage').processMessage
const service = require('../../../lib/helpers/service')
const aws = require('../../../lib/helpers/aws')
+const redis = require('../../../lib/helpers/redis')
const Message = require('../../../lib/models/message')
const v2MessageMapping = require('../../../lib/models/v2MessageMapping')
const nwsAlert = { bodyXml: fs.readFileSync(path.join(__dirname, 'data', 'nws-alert.xml'), 'utf8') }
@@ -29,6 +30,17 @@ const expectResponse = (response, putQuery, severity = 'Minor', status = 'Test',
expectResponseAndPutQuery(response, putQuery, status, msgType, references, previousReferences)
expectMessageV1(new Message(putQuery.values[3]), severity, status, references, previousReferences, quickdialNumber)
expectMessageV2(new Message(putQuery.values[10]), severity, status, references, previousReferences, quickdialNumber)
+ expectRedisSet(identifier)
+}
+
+const expectRedisSet = (identifier) => {
+ Code.expect(redis.set.calledOnce).to.be.true()
+ const [key, value] = redis.set.firstCall.args
+ Code.expect(key).to.equal(identifier)
+ Code.expect(value).to.be.an.object()
+ Code.expect(value.identifier).to.equal(identifier)
+ Code.expect(value.alert).to.not.be.empty()
+ Code.expect(value.alert_v2).to.not.be.empty()
}
const expectResponseAndPutQuery = (response, putQuery, status, msgType, references, previousReferences) => {
@@ -151,6 +163,8 @@ lab.experiment('processMessage', () => {
identifier: '4eb3b7350ab7aa443650fc9351f2'
}]
})
+ // mock redis
+ sinon.stub(redis, 'set').resolves('OK')
})
lab.afterEach(() => {
@@ -169,14 +183,17 @@ lab.experiment('processMessage', () => {
})
// do alert and test output xml
+ redis.set.resetHistory()
let response = await processMessage(nwsAlert)
expectResponse(response, putQuery, 'Minor')
// do warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Moderate') })
expectResponse(response, putQuery, 'Moderate')
// do severe warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Severe') })
expectResponse(response, putQuery, 'Severe')
})
@@ -192,14 +209,17 @@ lab.experiment('processMessage', () => {
putQuery = query
})
// do alert and test output xml
+ redis.set.resetHistory()
let response = await processMessage(nwsAlert)
expectResponse(response, putQuery, 'Minor')
// do warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Moderate') })
expectResponse(response, putQuery, 'Moderate')
// do severe warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Severe') })
expectResponse(response, putQuery, 'Severe')
})
@@ -212,14 +232,17 @@ lab.experiment('processMessage', () => {
})
// do alert and test output xml
+ redis.set.resetHistory()
let response = await processMessage(nwsAlert)
expectResponse(response, putQuery, 'Minor', 'Actual')
// do warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Moderate') })
expectResponse(response, putQuery, 'Moderate', 'Actual')
// do severe warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Severe') })
expectResponse(response, putQuery, 'Severe', 'Actual')
})
@@ -242,14 +265,17 @@ lab.experiment('processMessage', () => {
})
// do alert and test output xml
+ redis.set.resetHistory()
let response = await processMessage(nwsAlert)
expectResponse(response, putQuery, 'Minor', 'Test', 'Update', true)
// do warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Moderate') })
expectResponse(response, putQuery, 'Moderate', 'Test', 'Update', true)
// do severe warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Severe') })
expectResponse(response, putQuery, 'Severe', 'Test', 'Update', true)
})
@@ -273,14 +299,17 @@ lab.experiment('processMessage', () => {
})
// do alert and test output xml
+ redis.set.resetHistory()
let response = await processMessage(nwsAlert)
expectResponse(response, putQuery, 'Minor', 'Actual', 'Update', true)
// do warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Moderate') })
expectResponse(response, putQuery, 'Moderate', 'Actual', 'Update', true)
// do severe warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Severe') })
expectResponse(response, putQuery, 'Severe', 'Actual', 'Update', true)
})
@@ -308,14 +337,17 @@ lab.experiment('processMessage', () => {
const alert = { bodyXml: nwsAlert.bodyXml.replace('quickdial code: 210010.', '') }
// do alert and test output xml
+ redis.set.resetHistory()
let response = await processMessage(alert)
expectResponse(response, putQuery, 'Minor', 'Test', 'Update', true, true, false)
// do warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: alert.bodyXml.replace('Minor', 'Moderate') })
expectResponse(response, putQuery, 'Moderate', 'Test', 'Update', true, true, false)
// do severe warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: alert.bodyXml.replace('Minor', 'Severe') })
expectResponse(response, putQuery, 'Severe', 'Test', 'Update', true, true, false)
})
@@ -341,14 +373,17 @@ lab.experiment('processMessage', () => {
})
// do alert and test output xml
+ redis.set.resetHistory()
let response = await processMessage(nwsAlert)
expectResponse(response, putQuery, 'Minor', 'Actual', 'Update', true, true)
// do warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Moderate') })
expectResponse(response, putQuery, 'Moderate', 'Actual', 'Update', true, true)
// do severe warning and test output xml
+ redis.set.resetHistory()
response = await processMessage({ bodyXml: nwsAlert.bodyXml.replace('Minor', 'Severe') })
expectResponse(response, putQuery, 'Severe', 'Actual', 'Update', true, true)
})
diff --git a/test/lib/functions/processMessageValidation.js b/test/lib/functions/processMessageValidation.js
index b288074..828b3d6 100644
--- a/test/lib/functions/processMessageValidation.js
+++ b/test/lib/functions/processMessageValidation.js
@@ -16,13 +16,15 @@ const fakeService = {
const fakeSchema = { validateAsync: async () => ({ error: null }) }
const fakeAws = { email: { publishMessage: sinon.stub() } }
+const fakeRedis = { set: sinon.stub().resolves('OK') }
const loadWithValidateMock = (validateMock) => {
return Proxyquire('../../../lib/functions/processMessage', {
'xmllint-wasm': { validateXML: validateMock },
'../helpers/service': fakeService,
'../schemas/processMessageEventSchema': fakeSchema,
- '../helpers/aws': fakeAws
+ '../helpers/aws': fakeAws,
+ '../helpers/redis': fakeRedis
}).processMessage
}
@@ -74,10 +76,12 @@ lab.experiment('processMessage validation logging', () => {
lab.test('Processes a message correctly if valid with actual xmllint', async () => {
process.env.CPX_SNS_TOPIC = true
const awsStub = { email: { publishMessage: sinon.stub() } }
+ const redisStub = { set: sinon.stub().resolves('OK') }
const processMessage = Proxyquire('../../../lib/functions/processMessage', {
'../helpers/service': fakeService,
'../schemas/processMessageEventSchema': fakeSchema,
- '../helpers/aws': awsStub
+ '../helpers/aws': awsStub,
+ '../helpers/redis': redisStub
}).processMessage
const ret = await processMessage(nwsAlert)
@@ -89,6 +93,15 @@ lab.experiment('processMessage validation logging', () => {
Code.expect(ret.body.status).to.equal('Test')
Code.expect(awsStub.email.publishMessage.callCount).to.equal(0)
+
+ // Verify redis.set was called correctly
+ Code.expect(redisStub.set.calledOnce).to.be.true()
+ const [key, value] = redisStub.set.firstCall.args
+ Code.expect(key).to.equal('4eb3b7350ab7aa443650fc9351f02940E')
+ Code.expect(value).to.be.an.object()
+ Code.expect(value.identifier).to.equal('4eb3b7350ab7aa443650fc9351f02940E')
+ Code.expect(value.alert).to.not.be.empty()
+ Code.expect(value.alert_v2).to.not.be.empty()
})
lab.test('Throws validation errors for empty fields', async () => {
diff --git a/test/lib/helpers/message.js b/test/lib/helpers/message.js
index 71e68f6..9ac0e05 100644
--- a/test/lib/helpers/message.js
+++ b/test/lib/helpers/message.js
@@ -8,6 +8,7 @@ const fs = require('fs')
const path = require('path')
const { getMessage } = require('../../../lib/helpers/message')
const service = require('../../../lib/helpers/service')
+const redis = require('../../../lib/helpers/redis')
const getMessageXmlInvalid = fs.readFileSync(path.join(__dirname, '..', 'functions', 'data', 'getMessage-invalid.xml'), 'utf8')
const getMessageXmlValid = fs.readFileSync(path.join(__dirname, '..', 'functions', 'data', 'getMessage-valid.xml'), 'utf8')
let event
@@ -19,6 +20,13 @@ lab.experiment('getMessage helper', () => {
id: '4eb3b7350ab7aa443650fc9351f'
}
}
+ // Mock redis by default to return null (cache miss)
+ sinon.stub(redis, 'get').resolves(null)
+ sinon.stub(redis, 'set').resolves('OK')
+ })
+
+ lab.afterEach(() => {
+ sinon.restore()
})
lab.experiment('getMessage v1 (v2=false)', () => {
@@ -332,4 +340,116 @@ lab.experiment('getMessage helper', () => {
}
})
})
+
+ lab.experiment('Redis caching behavior', () => {
+ const mockMessage = {
+ alert: 'cached v1',
+ alert_v2: 'cached v2'
+ }
+
+ lab.test('Uses cached message from redis when available (v1)', async () => {
+ redis.get.resolves(mockMessage)
+ const serviceStub = sinon.stub(service, 'getMessage')
+
+ const ret = await getMessage(event, false)
+
+ Code.expect(redis.get.calledOnce).to.be.true()
+ Code.expect(redis.get.calledWith('4eb3b7350ab7aa443650fc9351f')).to.be.true()
+ Code.expect(serviceStub.called).to.be.false()
+ Code.expect(redis.set.called).to.be.false()
+ Code.expect(ret.body).to.equal('cached v1')
+ })
+
+ lab.test('Uses cached message from redis when available (v2)', async () => {
+ redis.get.resolves(mockMessage)
+ const serviceStub = sinon.stub(service, 'getMessage')
+
+ const ret = await getMessage(event, true)
+
+ Code.expect(redis.get.calledOnce).to.be.true()
+ Code.expect(redis.get.calledWith('4eb3b7350ab7aa443650fc9351f')).to.be.true()
+ Code.expect(serviceStub.called).to.be.false()
+ Code.expect(redis.set.called).to.be.false()
+ Code.expect(ret.body).to.equal('cached v2')
+ })
+
+ lab.test('Fetches from database and caches in redis on cache miss', async () => {
+ redis.get.resolves(null)
+ service.getMessage = () => Promise.resolve({
+ rows: [{
+ getmessage: {
+ alert: 'db v1',
+ alert_v2: 'db v2'
+ }
+ }]
+ })
+
+ const ret = await getMessage(event, false)
+
+ Code.expect(redis.get.calledOnce).to.be.true()
+ Code.expect(redis.get.calledWith('4eb3b7350ab7aa443650fc9351f')).to.be.true()
+ Code.expect(redis.set.calledOnce).to.be.true()
+ const [key, value] = redis.set.firstCall.args
+ Code.expect(key).to.equal('4eb3b7350ab7aa443650fc9351f')
+ Code.expect(value).to.equal({
+ alert: 'db v1',
+ alert_v2: 'db v2'
+ })
+ Code.expect(ret.body).to.equal('db v1')
+ })
+
+ lab.test('Caches entire message object with both alert versions', async () => {
+ redis.get.resolves(null)
+ const dbMessage = {
+ alert: 'full v1',
+ alert_v2: 'full v2',
+ extraField: 'should be cached too'
+ }
+ service.getMessage = () => Promise.resolve({
+ rows: [{ getmessage: dbMessage }]
+ })
+
+ await getMessage(event, true)
+
+ Code.expect(redis.set.calledOnce).to.be.true()
+ const [key, cachedValue] = redis.set.firstCall.args
+ Code.expect(key).to.equal('4eb3b7350ab7aa443650fc9351f')
+ Code.expect(cachedValue).to.equal(dbMessage)
+ })
+
+ lab.test('Does not cache when database returns no results', async () => {
+ redis.get.resolves(null)
+ service.getMessage = () => Promise.resolve({ rows: [] })
+
+ try {
+ await getMessage(event, false)
+ } catch (err) {
+ Code.expect(redis.set.called).to.be.false()
+ Code.expect(err.message).to.equal('No message found')
+ }
+ })
+
+ lab.test('Does not cache when database throws error', async () => {
+ redis.get.resolves(null)
+ service.getMessage = () => Promise.reject(new Error('DB error'))
+
+ try {
+ await getMessage(event, false)
+ } catch (err) {
+ Code.expect(redis.set.called).to.be.false()
+ Code.expect(err.message).to.equal('DB error')
+ }
+ })
+
+ lab.test('Uses correct cache key from event pathParameters', async () => {
+ event.pathParameters.id = 'abc123def456'
+ redis.get.resolves(mockMessage)
+ const serviceStub = sinon.stub(service, 'getMessage')
+
+ await getMessage(event, false)
+
+ Code.expect(redis.get.calledWith('abc123def456')).to.be.true()
+ Code.expect(serviceStub.called).to.be.false()
+ })
+ })
})
diff --git a/test/lib/helpers/redis.js b/test/lib/helpers/redis.js
new file mode 100644
index 0000000..f3d95ab
--- /dev/null
+++ b/test/lib/helpers/redis.js
@@ -0,0 +1,246 @@
+'use strict'
+
+const Lab = require('@hapi/lab')
+const Code = require('@hapi/code')
+const sinon = require('sinon')
+const Proxyquire = require('proxyquire')
+
+const lab = exports.lab = Lab.script()
+
+const ORIGINAL_ENV = process.env
+
+lab.experiment('redis helper', () => {
+ let redis
+ let mockRedisInstance
+ let mockRedis
+
+ lab.beforeEach(() => {
+ // Mock environment
+ process.env = { ...ORIGINAL_ENV }
+ process.env.CPX_REDIS_HOST = 'mock-redis-host'
+ process.env.CPX_REDIS_PORT = '6379'
+
+ // Mock ioredis client
+ mockRedisInstance = {
+ get: sinon.stub(),
+ set: sinon.stub(),
+ setex: sinon.stub(),
+ on: sinon.stub(),
+ status: 'ready'
+ }
+
+ mockRedis = sinon.stub().returns(mockRedisInstance)
+
+ // Load module with mocked ioredis
+ redis = Proxyquire('../../../lib/helpers/redis', {
+ ioredis: mockRedis
+ })
+ })
+
+ lab.afterEach(() => {
+ sinon.restore()
+ process.env = ORIGINAL_ENV
+ })
+
+ lab.test('get initializes redis client with correct config', async () => {
+ mockRedisInstance.get.resolves(null)
+
+ await redis.get('test-key')
+
+ Code.expect(mockRedis.calledOnce).to.be.true()
+ Code.expect(mockRedis.firstCall.args[0]).to.equal({
+ host: 'mock-redis-host',
+ maxRetriesPerRequest: 3,
+ port: '6379',
+ connectTimeout: 10000,
+ tls: undefined
+ })
+ })
+
+ lab.test('get initializes redis client with correct config with tls', async () => {
+ mockRedisInstance.get.resolves(null)
+ process.env.CPX_REDIS_TLS = 'true'
+ await redis.get('test-key')
+
+ Code.expect(mockRedis.calledOnce).to.be.true()
+ Code.expect(mockRedis.firstCall.args[0]).to.equal({
+ host: 'mock-redis-host',
+ maxRetriesPerRequest: 3,
+ port: '6379',
+ connectTimeout: 10000,
+ tls: { checkServerIdentity: () => undefined }
+ })
+ })
+
+ lab.test('get retrieves and parses JSON value', async () => {
+ const mockValue = { foo: 'bar', count: 42 }
+ mockRedisInstance.get.resolves(JSON.stringify(mockValue))
+
+ const result = await redis.get('test-key')
+
+ Code.expect(mockRedisInstance.get.calledWith('test-key')).to.be.true()
+ Code.expect(result).to.equal(mockValue)
+ })
+
+ lab.test('get returns string value when not JSON', async () => {
+ mockRedisInstance.get.resolves('plain-string-value')
+
+ const result = await redis.get('test-key')
+
+ Code.expect(result).to.equal('plain-string-value')
+ })
+
+ lab.test('get returns string value when JSON.parse fails', async () => {
+ mockRedisInstance.get.resolves('{invalid json')
+
+ const result = await redis.get('test-key')
+
+ Code.expect(result).to.equal('{invalid json')
+ })
+
+ lab.test('get returns null when key does not exist', async () => {
+ mockRedisInstance.get.resolves(null)
+
+ const result = await redis.get('non-existent-key')
+
+ Code.expect(result).to.be.null()
+ })
+
+ lab.test('set stores object as JSON string', async () => {
+ const mockObject = { foo: 'bar', count: 42 }
+ mockRedisInstance.set.resolves('OK')
+
+ await redis.set('test-key', mockObject)
+
+ Code.expect(mockRedisInstance.set.calledWith('test-key', JSON.stringify(mockObject))).to.be.true()
+ })
+
+ lab.test('set stores string value directly', async () => {
+ mockRedisInstance.set.resolves('OK')
+
+ await redis.set('test-key', 'string-value')
+
+ Code.expect(mockRedisInstance.set.calledWith('test-key', 'string-value')).to.be.true()
+ })
+
+ lab.test('set uses setex when ttl is 0', async () => {
+ mockRedisInstance.setex.resolves('OK')
+
+ await redis.set('test-key', 'value', 0)
+
+ Code.expect(mockRedisInstance.set.called).to.be.true()
+ Code.expect(mockRedisInstance.setex.called).to.be.false()
+ })
+
+ lab.test('set stores number value directly', async () => {
+ mockRedisInstance.set.resolves('OK')
+
+ await redis.set('test-key', 12345)
+
+ Code.expect(mockRedisInstance.set.calledWith('test-key', 12345)).to.be.true()
+ })
+
+ lab.test('recreates client when status is end', async () => {
+ // First call creates client
+ mockRedisInstance.get.resolves('value1')
+ await redis.get('key1')
+
+ Code.expect(mockRedis.calledOnce).to.be.true()
+
+ // Simulate client ending
+ mockRedisInstance.status = 'end'
+
+ // Second call should recreate client
+ await redis.get('key2')
+
+ Code.expect(mockRedis.calledTwice).to.be.true()
+ })
+
+ lab.test('recreates client when status is close', async () => {
+ // First call creates client
+ mockRedisInstance.get.resolves('value1')
+ await redis.get('key1')
+
+ Code.expect(mockRedis.calledOnce).to.be.true()
+
+ // Simulate client closing
+ mockRedisInstance.status = 'close'
+
+ // Second call should recreate client
+ await redis.get('key2')
+
+ Code.expect(mockRedis.calledTwice).to.be.true()
+ })
+
+ lab.test('registers error event handler on client', async () => {
+ mockRedisInstance.get.resolves(null)
+
+ await redis.get('test-key')
+
+ Code.expect(mockRedisInstance.on.calledWith('error')).to.be.true()
+ })
+
+ lab.test('registers connect event handler on client', async () => {
+ mockRedisInstance.get.resolves(null)
+
+ await redis.get('test-key')
+
+ Code.expect(mockRedisInstance.on.calledWith('connect')).to.be.true()
+ })
+
+ lab.test('error handler logs errors', async () => {
+ const consoleErrorStub = sinon.stub(console, 'error')
+ mockRedisInstance.get.resolves(null)
+
+ await redis.get('test-key')
+
+ const errorHandler = mockRedisInstance.on.getCalls().find(call => call.args[0] === 'error')
+ const mockError = new Error('Connection failed')
+ errorHandler.args[1](mockError)
+
+ Code.expect(consoleErrorStub.calledWith('Redis connection error:', mockError)).to.be.true()
+
+ consoleErrorStub.restore()
+ })
+
+ lab.test('connect handler logs success', async () => {
+ const consoleLogStub = sinon.stub(console, 'log')
+ mockRedisInstance.get.resolves(null)
+
+ await redis.get('test-key')
+
+ const connectHandler = mockRedisInstance.on.getCalls().find(call => call.args[0] === 'connect')
+ connectHandler.args[1]()
+
+ Code.expect(consoleLogStub.calledWith('Redis connected successfully')).to.be.true()
+
+ consoleLogStub.restore()
+ })
+
+ lab.test('get initializes client automatically on first call', async () => {
+ mockRedisInstance.get.resolves('"test-value"')
+
+ await redis.get('test-key')
+
+ Code.expect(mockRedis.called).to.be.true()
+ })
+
+ lab.test('set initializes client automatically on first call', async () => {
+ mockRedisInstance.set.resolves('OK')
+
+ await redis.set('test-key', 'value')
+
+ Code.expect(mockRedis.called).to.be.true()
+ })
+
+ lab.test('reuses existing client across multiple calls', async () => {
+ mockRedisInstance.get.resolves(null)
+ mockRedisInstance.set.resolves('OK')
+
+ await redis.get('key1')
+ await redis.set('key2', 'value')
+ await redis.get('key3')
+
+ Code.expect(mockRedis.calledOnce).to.be.true()
+ })
+})
diff --git a/test/lib/models/message.js b/test/lib/models/message.js
index 66ea022..e881793 100644
--- a/test/lib/models/message.js
+++ b/test/lib/models/message.js
@@ -197,10 +197,11 @@ lab.experiment('Message class', () => {
Code.expect(xmlOut).to.include('4eb3b7350ab7aa443650fc9351f02940E')
})
- lab.test('putQuery generates SQL insert with correct values', () => {
- const sql = message.putQuery(message, messageV2)
+ lab.test('putQuery generates message for redis and SQL insert with correct values', () => {
+ const { message: redisMessage, query: sql } = message.putQuery(message, messageV2)
+
+ // Verify SQL query
Code.expect(sql.text).to.equal('INSERT INTO "messages" ("identifier", "msg_type", "references", "alert", "fwis_code", "expires", "sent", "created", "identifier_v2", "references_v2", "alert_v2") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)')
- // TODO need to test for more values and v2 values here
Code.expect(sql.values[0]).to.equal('4eb3b7350ab7aa443650fc9351f02940E')
Code.expect(sql.values[1]).to.equal('Alert')
Code.expect(sql.values[2]).to.be.empty()
@@ -212,6 +213,16 @@ lab.experiment('Message class', () => {
Code.expect(sql.values[8]).to.equal('4eb3b7350ab7aa443650fc9351f02940E')
Code.expect(sql.values[9]).to.be.empty()
Code.expect(sql.values[10]).to.not.be.empty()
+
+ // Verify redis message object
+ Code.expect(redisMessage).to.be.an.object()
+ Code.expect(redisMessage.identifier).to.equal('4eb3b7350ab7aa443650fc9351f02940E')
+ Code.expect(redisMessage.alert).to.not.be.empty()
+ Code.expect(redisMessage.alert_v2).to.not.be.empty()
+ Code.expect(redisMessage.alert).to.be.a.string()
+ Code.expect(redisMessage.alert_v2).to.be.a.string()
+ Code.expect(redisMessage.alert).to.include('4eb3b7350ab7aa443650fc9351f02940E')
+ Code.expect(redisMessage.alert_v2).to.include('4eb3b7350ab7aa443650fc9351f02940E')
})
lab.test('blank message results in blank fields', () => {