Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 302 additions & 0 deletions tests/unit/handlers/finance.handlers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
'use strict'

const test = require('brittle')
const {
getEnergyBalance,
processConsumptionData,
processTransactionData,
processPriceData,
extractCurrentPrice,
processEnergyData,
extractNominalPower,
processCostsData,
calculateSummary
} = require('../../../workers/lib/server/handlers/finance.handlers')

test('getEnergyBalance - happy path', async (t) => {
const dayTs = 1700006400000
const mockCtx = {
conf: {
orks: [{ rpcPublicKey: 'key1' }]
},
net_r0: {
jRequest: async (key, method, payload) => {
if (method === 'tailLogCustomRangeAggr') {
return [{ type: 'powermeter', data: [{ ts: dayTs, val: { site_power_w: 5000 } }], error: null }]
}
if (method === 'getWrkExtData') {
if (payload.query && payload.query.key === 'transactions') {
return [{ ts: dayTs, transactions: [{ ts: dayTs, changed_balance: 0.5 }] }]
}
if (payload.query && payload.query.key === 'HISTORICAL_PRICES') {
return [{ ts: dayTs, priceUSD: 40000 }]
}
if (payload.query && payload.query.key === 'current_price') {
return [{ currentPrice: 40000 }]
}
if (payload.query && payload.query.key === 'stats-history') {
return []
}
}
if (method === 'getGlobalConfig') {
return { nominalPowerAvailability_MW: 10 }
}
return {}
}
},
globalDataLib: {
getGlobalData: async () => []
}
}

const mockReq = {
query: { start: 1700000000000, end: 1700100000000, period: 'daily' }
}

const result = await getEnergyBalance(mockCtx, mockReq, {})
t.ok(result.log, 'should return log array')
t.ok(result.summary, 'should return summary')
t.ok(Array.isArray(result.log), 'log should be array')
t.pass()
})

test('getEnergyBalance - missing start throws', async (t) => {
const mockCtx = {
conf: { orks: [], site: 'test-site' },
net_r0: { jRequest: async () => ({}) },
globalDataLib: { getGlobalData: async () => [] }
}

const mockReq = { query: { end: 1700100000000 } }

try {
await getEnergyBalance(mockCtx, mockReq, {})
t.fail('should have thrown')
} catch (err) {
t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error')
}
t.pass()
})

test('getEnergyBalance - missing end throws', async (t) => {
const mockCtx = {
conf: { orks: [], site: 'test-site' },
net_r0: { jRequest: async () => ({}) },
globalDataLib: { getGlobalData: async () => [] }
}

const mockReq = { query: { start: 1700000000000 } }

try {
await getEnergyBalance(mockCtx, mockReq, {})
t.fail('should have thrown')
} catch (err) {
t.is(err.message, 'ERR_MISSING_START_END', 'should throw missing start/end error')
}
t.pass()
})

test('getEnergyBalance - invalid range throws', async (t) => {
const mockCtx = {
conf: { orks: [], site: 'test-site' },
net_r0: { jRequest: async () => ({}) },
globalDataLib: { getGlobalData: async () => [] }
}

const mockReq = { query: { start: 1700100000000, end: 1700000000000 } }

try {
await getEnergyBalance(mockCtx, mockReq, {})
t.fail('should have thrown')
} catch (err) {
t.is(err.message, 'ERR_INVALID_DATE_RANGE', 'should throw invalid range error')
}
t.pass()
})

test('getEnergyBalance - empty ork results', async (t) => {
const mockCtx = {
conf: {
orks: [{ rpcPublicKey: 'key1' }]
},
net_r0: {
jRequest: async () => ({})
},
globalDataBee: {
sub: () => ({
sub: () => ({
createReadStream: () => (async function * () {})()
})
})
}
}

const mockReq = {
query: { start: 1700000000000, end: 1700100000000, period: 'daily' }
}

const result = await getEnergyBalance(mockCtx, mockReq, {})
t.ok(result.log, 'should return log array')
t.ok(result.summary, 'should return summary')
t.is(result.log.length, 0, 'log should be empty with no data')
t.pass()
})

test('processConsumptionData - processes daily data from ORK', (t) => {
const results = [
[{ type: 'powermeter', data: [{ ts: 1700006400000, val: { site_power_w: 5000 } }], error: null }]
]

const daily = processConsumptionData(results)
t.ok(typeof daily === 'object', 'should return object')
t.ok(Object.keys(daily).length > 0, 'should have entries')
const key = Object.keys(daily)[0]
t.is(daily[key].powerW, 5000, 'should extract power from val')
t.pass()
})

test('processConsumptionData - processes object-keyed data', (t) => {
const results = [
[{ data: { 1700006400000: { site_power_w: 5000 } } }]
]

const daily = processConsumptionData(results)
t.ok(typeof daily === 'object', 'should return object')
t.pass()
})

test('processConsumptionData - handles error results', (t) => {
const results = [{ error: 'timeout' }]
const daily = processConsumptionData(results)
t.ok(typeof daily === 'object', 'should return object')
t.is(Object.keys(daily).length, 0, 'should be empty for error results')
t.pass()
})

test('processTransactionData - processes F2Pool data', (t) => {
const results = [
[{ ts: 1700006400000, transactions: [{ created_at: 1700006400, changed_balance: 0.001 }] }]
]

const daily = processTransactionData(results)
t.ok(typeof daily === 'object', 'should return object')
t.ok(Object.keys(daily).length > 0, 'should have entries')
const key = Object.keys(daily)[0]
t.is(daily[key].revenueBTC, 0.001, 'should use changed_balance directly as BTC')
t.pass()
})

test('processTransactionData - processes Ocean data', (t) => {
const results = [
[{ ts: 1700006400000, transactions: [{ ts: 1700006400, satoshis_net_earned: 50000000 }] }]
]

const daily = processTransactionData(results)
t.ok(typeof daily === 'object', 'should return object')
t.ok(Object.keys(daily).length > 0, 'should have entries')
const key = Object.keys(daily)[0]
t.is(daily[key].revenueBTC, 0.5, 'should convert sats to BTC')
t.pass()
})

test('processTransactionData - handles error results', (t) => {
const results = [{ error: 'timeout' }]
const daily = processTransactionData(results)
t.ok(typeof daily === 'object', 'should return object')
t.is(Object.keys(daily).length, 0, 'should be empty for error results')
t.pass()
})

test('processPriceData - processes mempool price data', (t) => {
const results = [
[{ ts: 1700006400000, priceUSD: 40000 }]
]

const daily = processPriceData(results)
t.ok(typeof daily === 'object', 'should return object')
t.ok(Object.keys(daily).length > 0, 'should have entries')
const key = Object.keys(daily)[0]
t.is(daily[key], 40000, 'should extract priceUSD')
t.pass()
})

test('extractCurrentPrice - extracts currentPrice from mempool data', (t) => {
const results = [
[{ currentPrice: 42000, blockHeight: 900000 }]
]
const price = extractCurrentPrice(results)
t.is(price, 42000, 'should extract currentPrice')
t.pass()
})

test('extractCurrentPrice - extracts priceUSD', (t) => {
const results = [
[{ ts: 1700006400000, priceUSD: 42000 }]
]
const price = extractCurrentPrice(results)
t.is(price, 42000, 'should extract priceUSD')
t.pass()
})

test('extractCurrentPrice - handles error results', (t) => {
const results = [{ error: 'timeout' }]
const price = extractCurrentPrice(results)
t.is(price, 0, 'should return 0 for error results')
t.pass()
})

test('processCostsData - processes dashboard format (energyCostsUSD)', (t) => {
const costs = [
{ region: 'site1', year: 2023, month: 11, energyCostsUSD: 30000, operationalCostsUSD: 6000 }
]

const result = processCostsData(costs)
t.ok(result['2023-11'], 'should have month key')
t.is(result['2023-11'].energyCostPerDay, 1000, 'should have daily energy cost (30000/30)')
t.is(result['2023-11'].operationalCostPerDay, 200, 'should have daily operational cost (6000/30)')
t.pass()
})

test('processCostsData - processes app-node format (energyCost)', (t) => {
const costs = [
{ site: 'site1', year: 2023, month: 11, energyCost: 30000, operationalCost: 6000 }
]

const result = processCostsData(costs)
t.ok(result['2023-11'], 'should have month key')
t.is(result['2023-11'].energyCostPerDay, 1000, 'should have daily energy cost (30000/30)')
t.is(result['2023-11'].operationalCostPerDay, 200, 'should have daily operational cost (6000/30)')
t.pass()
})

test('processCostsData - handles non-array input', (t) => {
const result = processCostsData(null)
t.ok(typeof result === 'object', 'should return object')
t.is(Object.keys(result).length, 0, 'should be empty')
t.pass()
})

test('calculateSummary - calculates from log entries', (t) => {
const log = [
{ revenueBTC: 0.5, revenueUSD: 20000, totalCostUSD: 5000, profitUSD: 15000, consumptionMWh: 100 },
{ revenueBTC: 0.3, revenueUSD: 12000, totalCostUSD: 3000, profitUSD: 9000, consumptionMWh: 60 }
]

const summary = calculateSummary(log)
t.is(summary.totalRevenueBTC, 0.8, 'should sum BTC revenue')
t.is(summary.totalRevenueUSD, 32000, 'should sum USD revenue')
t.is(summary.totalCostUSD, 8000, 'should sum costs')
t.is(summary.totalProfitUSD, 24000, 'should sum profit')
t.is(summary.totalConsumptionMWh, 160, 'should sum consumption')
t.ok(summary.avgCostPerMWh !== null, 'should calculate avg cost per MWh')
t.ok(summary.avgRevenuePerMWh !== null, 'should calculate avg revenue per MWh')
t.pass()
})

test('calculateSummary - handles empty log', (t) => {
const summary = calculateSummary([])
t.is(summary.totalRevenueBTC, 0, 'should be zero')
t.is(summary.totalRevenueUSD, 0, 'should be zero')
t.is(summary.avgCostPerMWh, null, 'should be null')
t.pass()
})
Loading