Feat: save highVolumeTxs on DB and plugins code refactor

Fix: remove unused module and add space before '('

Chore: add jest tests for transactionNotify
This commit is contained in:
Cesar 2020-12-04 19:58:17 +00:00 committed by Josh Harvey
parent 75bfb4b991
commit 2ced230020
8 changed files with 646 additions and 414 deletions

View file

@ -1,5 +1,8 @@
const _ = require('lodash/fp')
const prettyMs = require('pretty-ms')
const email = require('../email')
const {
PING,
STALE,
@ -97,4 +100,7 @@ function emailAlert(alert) {
}
}
module.exports = { alertSubject, printEmailAlerts }
const sendMessage = email.sendMessage
module.exports = { alertSubject, printEmailAlerts, sendMessage }

View file

@ -1,12 +1,16 @@
const { STALE, STALE_STATE } = require('./codes')
const _ = require('lodash/fp')
const queries = require('./queries')
const configManager = require('../new-config-manager')
const logger = require('../logger')
const machineLoader = require('../machine-loader')
const queries = require('./queries')
const settingsLoader = require('../new-settings-loader')
const customers = require('../customers')
const utils = require('./utils')
const emailFuncs = require('./email')
const smsFuncs = require('./sms')
const { STALE, STALE_STATE } = require('./codes')
function buildMessage(alerts, notifications) {
const smsEnabled = utils.isActive(notifications.sms)
@ -132,8 +136,79 @@ function checkStuckScreen(deviceEvents, machineName) {
return []
}
async function transactionNotify (tx, rec) {
const settings = await settingsLoader.loadLatest()
const notifSettings = configManager.getGlobalNotifications(settings.config)
const highValueTx = tx.fiat.gt(notifSettings.highValueTransaction || Infinity)
const isCashOut = tx.direction === 'cashOut'
// high value tx on database
if(highValueTx) {
queries.addHighValueTx(tx)
}
// alert through sms or email any transaction or high value transaction, if SMS || email alerts are enabled
const cashOutConfig = configManager.getCashOut(tx.deviceId, settings.config)
const zeroConfLimit = cashOutConfig.zeroConfLimit
const zeroConf = isCashOut && tx.fiat.lte(zeroConfLimit)
const notificationsEnabled = notifSettings.sms.transactions || notifSettings.email.transactions
const customerPromise = tx.customerId ? customers.getById(tx.customerId) : Promise.resolve({})
if (!notificationsEnabled && !highValueTx) return Promise.resolve()
if (zeroConf && isCashOut && !rec.isRedemption && !rec.error) return Promise.resolve()
if (!zeroConf && rec.isRedemption) return sendRedemptionMessage(tx.id, rec.error)
return Promise.all([
machineLoader.getMachineName(tx.deviceId),
customerPromise
])
.then(([machineName, customer]) => {
return utils.buildTransactionMessage(tx, rec, highValueTx, machineName, customer)
})
.then(([msg, highValueTx]) => sendTransactionMessage(msg, highValueTx))
}
function sendRedemptionMessage(txId, error) {
const subject = `Here's an update on transaction ${txId}`
const body = error
? `Error: ${error}`
: 'It was just dispensed successfully'
const rec = {
sms: {
body: `${subject} - ${body}`
},
email: {
subject,
body
}
}
return sendTransactionMessage(rec)
}
async function sendTransactionMessage(rec, isHighValueTx) {
const settings = await settingsLoader.loadLatest()
const notifications = configManager.getGlobalNotifications(settings.config)
let promises = []
const emailActive =
notifications.email.active &&
(notifications.email.transactions || isHighValueTx)
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
const smsActive =
notifications.sms.active &&
(notifications.sms.transactions || isHighValueTx)
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
return Promise.all(promises)
}
module.exports = {
transactionNotify,
checkNotification,
checkPings,
checkStuckScreen
checkStuckScreen,
sendRedemptionMessage
}

View file

@ -1,3 +1,12 @@
const dbm = require('../postgresql_interface')
const db = require('../db')
const { v4: uuidv4 } = require('uuid')
module.exports = { machineEvents: dbm.machineEvents }
const addHighValueTx = (tx) => {
const sql = `INSERT INTO notifications (id, type, device_id, message, created) values($1, $2, $3, $4, CURRENT_TIMESTAMP)`
const direction = tx.direction === "cashOut" ? 'cash-out' : 'cash-in'
const message = `${tx.fiat} ${tx.fiatCode} ${direction} transaction`
return db.oneOrNone(sql, [uuidv4(), 'highValueTransaction', tx.deviceId, message])
}
module.exports = { machineEvents: dbm.machineEvents, addHighValueTx }

View file

@ -1,5 +1,6 @@
const _ = require('lodash/fp')
const utils = require('./utils')
const sms = require('../sms')
function printSmsAlerts(alertRec, config) {
let alerts = []
@ -50,4 +51,6 @@ function printSmsAlerts(alertRec, config) {
return '[Lamassu] Errors reported: ' + displayAlertTypes.join(', ')
}
module.exports = { printSmsAlerts }
const sendMessage = sms.sendMessage
module.exports = { printSmsAlerts, sendMessage }

View file

@ -1,4 +1,15 @@
const BigNumber = require('../../../lib/bn')
const notifier = require('..')
const utils = require('../utils')
const queries = require("../queries")
const emailFuncs = require('../email')
const smsFuncs = require('../sms')
afterEach(() => {
// https://stackoverflow.com/questions/58151010/difference-between-resetallmocks-resetmodules-resetmoduleregistry-restoreallm
jest.restoreAllMocks()
})
// mock plugins object with mock data to test functions
const plugins = {
@ -33,32 +44,57 @@ const plugins = {
checkBalances: () => []
}
const devices = [
{
deviceId:
'7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4',
lastPing: '2020-11-16T13:11:03.169Z',
name: 'Abc123'
const tx = {
id: 'bec8d452-9ea2-4846-841b-55a9df8bbd00',
deviceId:
'490ab16ee0c124512dc769be1f3e7ee3894ce1e5b4b8b975e134fb326e551e88',
toAddress: 'bc1q7s4yy5n9vp6zhlf6mrw3cttdgx5l3ysr2mhc4v',
cryptoAtoms: BigNumber(252100),
cryptoCode: 'BTC',
fiat: BigNumber(55),
fiatCode: 'USD',
fee: null,
txHash: null,
phone: null,
error: null,
created: '2020-12-04T16:28:11.016Z',
send: true,
sendConfirmed: false,
timedout: false,
sendTime: null,
errorCode: null,
operatorCompleted: false,
sendPending: true,
cashInFee: BigNumber(2),
cashInFeeCrypto: BigNumber(9500),
minimumTx: 5,
customerId: '47ac1184-8102-11e7-9079-8f13a7117867',
txVersion: 6,
termsAccepted: false,
commissionPercentage: BigNumber(0.11),
rawTickerPrice: BigNumber(18937.4),
isPaperWallet: false,
direction: 'cashIn'
}
const notifSettings = {
email_active: false,
sms_active: true,
email_errors: false,
sms_errors: true,
sms_transactions: true,
highValueTransaction: Infinity, //this will make highValueTx always false
sms: {
active: true,
errors: true,
transactions: false // force early return
},
{
deviceId:
'9871e58aa2643ff9445cbc299b50397430ada75157d6c29b4c93548fff0f48f7',
lastPing: '2020-11-16T16:21:35.948Z',
name: 'Machine 2'
},
{
deviceId:
'5ae0d02dedeb77b6521bd5eb7c9159bdc025873fa0bcb6f87aaddfbda0c50913',
lastPing: '2020-11-19T15:07:57.089Z',
name: 'Machine 3'
},
{
deviceId:
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
lastPing: '2020-11-23T19:34:41.031Z',
name: 'New Machine 4 '
email: {
active: false,
errors: false,
transactions: false // force early return
}
]
}
test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled', async () => {
expect.assertions(1)
@ -116,17 +152,11 @@ test('Checkpings returns empty array as the value for the id prop, if the lastPi
})
})
afterEach(() => {
// https://stackoverflow.com/questions/58151010/difference-between-resetallmocks-resetmodules-resetmoduleregistry-restoreallm
jest.restoreAllMocks()
})
const utils = require('../utils')
test('Check notification resolves to undefined if shouldNotAlert is called and is true', async () => {
const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert')
mockShouldNotAlert.mockReturnValue(true)
const result = await notifier.checkNotification(plugins)
expect(mockShouldNotAlert).toHaveBeenCalledTimes(1)
expect(result).toBe(undefined)
@ -160,7 +190,6 @@ test('If no alert fingerprint and inAlert is true, exits on call to sendNoAlerts
expect(mockSendNoAlerts).toHaveBeenCalledTimes(1)
})
// vvv tests for checkstuckscreen...
test('checkStuckScreen returns [] when no events are found', () => {
expect(notifier.checkStuckScreen([], 'Abc123')).toEqual([])
@ -168,67 +197,141 @@ test('checkStuckScreen returns [] when no events are found', () => {
test('checkStuckScreen returns [] if most recent event is idle', () => {
// device_time is what matters for the sorting of the events by recency
expect(notifier.checkStuckScreen([{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id: 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":false}',
created: "2020-11-23T19:30:29.209Z",
device_time: "1999-11-23T19:30:29.177Z",
age: 157352628.123
}, {
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id: 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":true}',
created: "2020-11-23T19:30:29.209Z",
device_time: "2020-11-23T19:30:29.177Z",
age: 157352628.123
}])).toEqual([])
expect(
notifier.checkStuckScreen([
{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id:
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":false}',
created: '2020-11-23T19:30:29.209Z',
device_time: '1999-11-23T19:30:29.177Z',
age: 157352628.123
},
{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id:
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":true}',
created: '2020-11-23T19:30:29.209Z',
device_time: '2020-11-23T19:30:29.177Z',
age: 157352628.123
}
])
).toEqual([])
})
test('checkStuckScreen returns object array of length 1 with prop code: "STALE" if age > STALE_STATE', () => {
// there is an age 0 and an isIdle true in the first object but it will be below the second one in the sorting order and thus ignored
const result = notifier.checkStuckScreen([{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id: 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":true}',
created: "2020-11-23T19:30:29.209Z",
device_time: "1999-11-23T19:30:29.177Z",
age: 0
}, {
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id: 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":false}',
created: "2020-11-23T19:30:29.209Z",
device_time: "2020-11-23T19:30:29.177Z",
age: 157352628.123
}])
expect(result[0]).toMatchObject({code: "STALE"})
const result = notifier.checkStuckScreen([
{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id:
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":true}',
created: '2020-11-23T19:30:29.209Z',
device_time: '1999-11-23T19:30:29.177Z',
age: 0
},
{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id:
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":false}',
created: '2020-11-23T19:30:29.209Z',
device_time: '2020-11-23T19:30:29.177Z',
age: 157352628.123
}
])
expect(result[0]).toMatchObject({ code: 'STALE' })
})
test('checkStuckScreen returns empty array if age < STALE_STATE', () => {
const STALE_STATE = require("../codes").STALE_STATE
const result1 = notifier.checkStuckScreen([{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id: 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":false}',
created: "2020-11-23T19:30:29.209Z",
device_time: "2020-11-23T19:30:29.177Z",
age: 0
}])
const result2 = notifier.checkStuckScreen([{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id: 'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":false}',
created: "2020-11-23T19:30:29.209Z",
device_time: "2020-11-23T19:30:29.177Z",
age: STALE_STATE
}])
const STALE_STATE = require('../codes').STALE_STATE
const result1 = notifier.checkStuckScreen([
{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id:
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":false}',
created: '2020-11-23T19:30:29.209Z',
device_time: '2020-11-23T19:30:29.177Z',
age: 0
}
])
const result2 = notifier.checkStuckScreen([
{
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
device_id:
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
event_type: 'stateChange',
note: '{"state":"chooseCoin","isIdle":false}',
created: '2020-11-23T19:30:29.209Z',
device_time: '2020-11-23T19:30:29.177Z',
age: STALE_STATE
}
])
expect(result1).toEqual([])
expect(result2).toEqual([])
})
test("calls sendRedemptionMessage if !zeroConf and rec.isRedemption", async () => {
const configManager = require('../../new-config-manager')
const settingsLoader = require('../../new-settings-loader')
const loadLatest = jest.spyOn(settingsLoader, 'loadLatest')
const getGlobalNotifications = jest.spyOn(configManager, 'getGlobalNotifications')
const getCashOut = jest.spyOn(configManager, 'getCashOut')
// sendRedemptionMessage will cause this func to be called
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => rec)
getCashOut.mockReturnValue({zeroConfLimit: -Infinity})
loadLatest.mockReturnValue({})
getGlobalNotifications.mockReturnValue({... notifSettings, sms: { active: true, errors: true, transactions: true }})
const response = await notifier.transactionNotify(tx, {isRedemption: true})
// this type of response implies sendRedemptionMessage was called
expect(response[0]).toMatchObject({
sms: {
body: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00 - It was just dispensed successfully"
},
email: {
subject: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00",
body: 'It was just dispensed successfully'
}
})
})
test("calls sendTransactionMessage if !zeroConf and !rec.isRedemption", async () => {
const configManager = require('../../new-config-manager')
const settingsLoader = require('../../new-settings-loader')
const machineLoader = require('../../machine-loader')
const loadLatest = jest.spyOn(settingsLoader, 'loadLatest')
const getGlobalNotifications = jest.spyOn(configManager, 'getGlobalNotifications')
const getCashOut = jest.spyOn(configManager, 'getCashOut')
const getMachineName = jest.spyOn(machineLoader, 'getMachineName')
const buildTransactionMessage = jest.spyOn(utils, 'buildTransactionMessage')
// sendMessage on emailFuncs isn't called because it is disabled in getGlobalNotifications.mockReturnValue
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => ({prop: rec}))
buildTransactionMessage.mockImplementation(() => ["mock message", false])
getMachineName.mockReturnValue("mockMachineName")
getCashOut.mockReturnValue({zeroConfLimit: -Infinity})
loadLatest.mockReturnValue({})
getGlobalNotifications.mockReturnValue({... notifSettings, sms: { active: true, errors: true, transactions: true }})
const response = await notifier.transactionNotify(tx, {isRedemption: false})
// If the return object is this, it means the code went through all the functions expected to go through if
// getMachineName, buildTransactionMessage and sendTransactionMessage were called, in this order
expect(response).toEqual([{prop: 'mock message'}])
})

View file

@ -1,5 +1,7 @@
const _ = require('lodash/fp')
const crypto = require('crypto')
const coinUtils = require('../coin-utils')
const {
CODES_DISPLAY,
NETWORK_DOWN_TIME,
@ -96,6 +98,51 @@ function sendNoAlerts(plugins, smsEnabled, emailEnabled) {
return plugins.sendMessage(rec)
}
const buildTransactionMessage = (tx, rec, highValueTx, machineName, customer) => {
const isCashOut = tx.direction === 'cashOut'
const direction = isCashOut ? 'Cash Out' : 'Cash In'
const crypto = `${coinUtils.toUnit(tx.cryptoAtoms, tx.cryptoCode)} ${
tx.cryptoCode
}`
const fiat = `${tx.fiat} ${tx.fiatCode}`
const customerName = customer.name || customer.id
const phone = customer.phone ? `- Phone: ${customer.phone}` : ''
let status = null
if (rec.error) {
status = `Error - ${rec.error}`
} else {
status = !isCashOut
? 'Successful'
: !rec.isRedemption
? 'Successful & awaiting redemption'
: 'Successful & dispensed'
}
const body = `
- Transaction ID: ${tx.id}
- Status: ${status}
- Machine name: ${machineName}
- ${direction}
- ${fiat}
- ${crypto}
- Customer: ${customerName}
${phone}
`
const smsSubject = `A ${highValueTx ? 'high value ' : ''}${direction.toLowerCase()} transaction just happened at ${machineName} for ${fiat}`
const emailSubject = `A ${highValueTx ? 'high value ' : ''}transaction just happened`
return [{
sms: {
body: `${smsSubject} ${status}`
},
email: {
emailSubject,
body
}
}, highValueTx]
}
module.exports = {
codeDisplay,
parseEventNote,
@ -107,5 +154,6 @@ module.exports = {
setAlertFingerprint,
shouldNotAlert,
buildAlertFingerprint,
sendNoAlerts
sendNoAlerts,
buildTransactionMessage
}