diff --git a/README.md b/README.md index effe51a3..6c2cb961 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Computes the optimal capacity allocation in order to get the best price on cover - [Quote Route](#quote-route) - [Capacity Route](#capacity-route) - [Capacity Route for a specific product](#capacity-route-for-a-specific-product) + - [Capacity Route for all products in a pool](#capacity-route-for-all-products-in-a-pool) + - [Capacity Route for a specific product in a pool](#capacity-route-for-a-specific-product-in-a-pool) ## Setup @@ -49,4 +51,24 @@ best available combination of pools for the premium. - **OpenAPI**: [v2/api/docs/#/Capacity/get_v2_capacity__productId_](https://api.nexusmutual.io/v2/api/docs/#/Capacity/get_v2_capacity__productId_) - **Description**: Returns the current capacity for a specific product for a period of 30 days if no period query param is specified. +### Capacity Route for all products in a pool +- **URL**: `/v2/capacity/pools/{poolId}` +- **Method**: `GET` +- **OpenAPI**: [v2/api/docs/#/Capacity/get_v2_capacity_pools__poolId_](https://api.nexusmutual.io/v2/api/docs/#/Capacity/ +get_v2_capacity_pools__poolId_) + +- **Description**: Returns the current capacity for all products in a specific pool for a period of 30 days if no period query param is specified. +- **Parameters**: + - `poolId`: Required path parameter specifying the pool ID. + - `period`: Optional query parameter specifying the period in days (default is 30, range is 28-365). +### Capacity Route for a specific product in a pool +- **URL**: `/v2/capacity/pools/{poolId}/products/{productId}` +- **Method**: `GET` +- **OpenAPI**: [v2/api/docs/#/Capacity/get_v2_capacity_pools__poolId__products__productId_](https://api.nexusmutual.io/v2/api/docs/#/Capacity/get_v2_capacity_pools__poolId__products__productId_) + +- **Description**: Returns the current capacity for a specific product in a specific pool for a period of 30 days if no period query param is specified. +- **Parameters**: + - `poolId`: Required path parameter specifying the pool ID. + - `productId`: Required path parameter specifying the product ID. + - `period`: Optional query parameter specifying the period in days (default is 30, range is 28-365). diff --git a/package-lock.json b/package-lock.json index 77237f2a..bac0008b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cover-router", - "version": "2.3.8", + "version": "2.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "cover-router", - "version": "2.3.8", + "version": "2.4.0", "license": "ISC", "dependencies": { "@nexusmutual/deployments": "^2.10.0", diff --git a/package.json b/package.json index 74ede524..cd4fe494 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cover-router", - "version": "2.3.8", + "version": "2.4.0", "description": "Cover Router", "main": "src/index.js", "engines": { diff --git a/src/lib/capacityEngine.js b/src/lib/capacityEngine.js index 6ca8687e..eecc0d6e 100644 --- a/src/lib/capacityEngine.js +++ b/src/lib/capacityEngine.js @@ -7,6 +7,39 @@ const { selectAsset, selectProduct, selectProductPools } = require('../store/sel const { WeiPerEther, Zero } = ethers.constants; +const SECONDS_PER_DAY = BigNumber.from(24 * 3600); +const BASIS_POINTS = 10000; + +/** + * Calculates the utilization rate of the capacity. + * + * @param {BigNumber} capacityAvailableNXM - The amount of capacity available in NXM. + * @param {BigNumber} capacityUsedNXM - The amount of capacity used in NXM. + * @returns {BigNumber} The utilization rate as a BigNumber, expressed in basis points (0-10,000). + * Returns undefined if capacity in NXM is missing. + */ +function getUtilizationRate(capacityAvailableNXM, capacityUsedNXM) { + if (!capacityAvailableNXM || !capacityUsedNXM) { + return undefined; + } + + const totalCapacity = capacityAvailableNXM.add(capacityUsedNXM); + if (totalCapacity.isZero()) { + return BigNumber.from(0); + } + + return capacityUsedNXM.mul(BASIS_POINTS).div(totalCapacity); +} + +/** + * Calculates capacity and pricing data for a specific tranche of product pools. + * + * @param {Array} productPools - Array of product pool objects. + * @param {number} firstUsableTrancheIndex - Index of the first usable tranche. + * @param {boolean} useFixedPrice - Flag indicating whether to use fixed pricing. + * @param {BigNumber} now - Current timestamp in seconds. + * @returns {Object} An object containing capacity used, capacity available, minimum price, and total premium. + */ function calculateProductDataForTranche(productPools, firstUsableTrancheIndex, useFixedPrice, now) { return productPools.reduce( (accumulated, pool) => { @@ -18,9 +51,10 @@ function calculateProductDataForTranche(productPools, firstUsableTrancheIndex, u const total = trancheCapacities.reduce((total, capacity) => total.add(capacity), Zero); const unused = trancheCapacities.reduce((available, capacity, index) => { + const allocationDifference = capacity.sub(allocations[index]); return index < firstUsableTrancheIndex - ? available.add(bnMin(capacity.sub(allocations[index]), Zero)) // only carry over the negative - : available.add(capacity.sub(allocations[index])); + ? available.add(bnMin(allocationDifference, Zero)) // only carry over the negative + : available.add(allocationDifference); }, Zero); const availableCapacity = bnMax(unused, Zero); @@ -71,31 +105,81 @@ function calculateProductDataForTranche(productPools, firstUsableTrancheIndex, u ); } -function capacityEngine(store, productIds, time, period = 30) { - const { assets, assetRates } = store.getState(); - const capacities = []; - const ids = productIds.length === 0 ? Object.keys(store.getState().products) : [...productIds]; +/** + * Retrieves all product IDs that are associated with a specific pool. + * + * @param {Object} store - The Redux store containing application state. + * @param {number|string} poolId - The ID of the pool to filter products by. + * @returns {Array} An array of product IDs associated with the specified pool. + */ +function getProductsInPool(store, poolId) { + const { products } = store.getState(); + return Object.keys(products).filter(productId => { + const productPools = selectProductPools(store, productId, poolId); + return productPools?.length > 0; + }); +} + +/** + * Calculates tranche indices for capacity calculations based on time and product data. + * + * @param {BigNumber} time - The current timestamp in seconds. + * @param {Object} product - The product object containing product details. + * @param {number} period - The coverage period in days. + * @returns {Object} Contains indices for the first usable tranche / first usable tranche for the maximum period. + */ +function calculateTrancheInfo(time, product, period) { + const firstActiveTrancheId = calculateTrancheId(time); + const gracePeriodExpiration = time.add(SECONDS_PER_DAY.mul(period)).add(product.gracePeriod); + const firstUsableTrancheId = calculateTrancheId(gracePeriodExpiration); + const firstUsableTrancheIndex = firstUsableTrancheId - firstActiveTrancheId; + const firstUsableTrancheForMaxPeriodId = calculateTrancheId(time.add(MAX_COVER_PERIOD).add(product.gracePeriod)); + const firstUsableTrancheForMaxPeriodIndex = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; + + return { + firstUsableTrancheIndex, + firstUsableTrancheForMaxPeriodIndex, + }; +} + +/** + * Calculates the capacity and pricing information for products and pools. + * + * @param {Object} store - The Redux store containing application state. + * @param {Object} [options={}] - Optional parameters for capacity calculation. + * @param {number|null} [options.poolId=null] - The ID of the pool to filter products by. + * @param {Array} [options.productIds=[]] - Array of product IDs to process. + * @param {number} [options.period=30] - The coverage period in days. + * @returns {Array} An array of capacity information objects for each product. + */ +function capacityEngine(store, { poolId = null, productIds = [], period = 30 } = {}) { + const { assets, assetRates, products } = store.getState(); const now = BigNumber.from(Date.now()).div(1000); + const capacities = []; - for (const productId of ids) { + let productIdsToProcess; + if (productIds.length > 0) { + productIdsToProcess = [...productIds]; + } else if (poolId !== null) { + // If only poolId is provided, get all products in that pool + productIdsToProcess = getProductsInPool(store, poolId); + } else { + // If neither productIds nor poolId is provided, process all products + productIdsToProcess = Object.keys(products); + } + + for (const productId of productIdsToProcess) { const product = selectProduct(store, productId); if (!product) { continue; } - const secondsPerDay = BigNumber.from(24 * 3600); - - const firstActiveTrancheId = calculateTrancheId(time); - const gracePeriodExpiration = time.add(secondsPerDay.mul(period)).add(product.gracePeriod); - const firstUsableTrancheId = calculateTrancheId(gracePeriodExpiration); - const firstUsableTrancheIndex = firstUsableTrancheId - firstActiveTrancheId; - const firstUsableTrancheForMaxPeriodId = calculateTrancheId(time.add(MAX_COVER_PERIOD).add(product.gracePeriod)); - const firstUsableTrancheForMaxPeriodIndex = firstUsableTrancheForMaxPeriodId - firstActiveTrancheId; - - const productPools = selectProductPools(store, productId); + const { firstUsableTrancheIndex, firstUsableTrancheForMaxPeriodIndex } = calculateTrancheInfo(now, product, period); + const productPools = selectProductPools(store, productId, poolId); if (product.useFixedPrice) { + // Fixed Price const productData = calculateProductDataForTranche(productPools, firstUsableTrancheIndex, true, now); const { capacityAvailableNXM, capacityUsedNXM, minPrice, totalPremium } = productData; @@ -114,10 +198,12 @@ function capacityEngine(store, productIds, time, period = 30) { productId: Number(productId), availableCapacity: capacityInAssets, usedCapacity: capacityUsedNXM, + utilizationRate: getUtilizationRate(capacityAvailableNXM, capacityUsedNXM), minAnnualPrice: minPrice, maxAnnualPrice, }); } else { + // Non-fixed Price let productData = {}; let maxAnnualPrice = BigNumber.from(0); @@ -149,6 +235,7 @@ function capacityEngine(store, productIds, time, period = 30) { productId: Number(productId), availableCapacity: capacityInAssets, usedCapacity: capacityUsedNXM, + utilizationRate: getUtilizationRate(capacityAvailableNXM, capacityUsedNXM), minAnnualPrice: minPrice, maxAnnualPrice, }); @@ -158,4 +245,7 @@ function capacityEngine(store, productIds, time, period = 30) { return capacities; } -module.exports = capacityEngine; +module.exports = { + capacityEngine, + getUtilizationRate, +}; diff --git a/src/routes/capacity.js b/src/routes/capacity.js index 8be9b9a9..1f18343f 100644 --- a/src/routes/capacity.js +++ b/src/routes/capacity.js @@ -1,22 +1,23 @@ const { ethers, BigNumber } = require('ethers'); const express = require('express'); -const capacityEngine = require('../lib/capacityEngine'); +const { capacityEngine } = require('../lib/capacityEngine'); const { asyncRoute } = require('../lib/helpers'); const router = express.Router(); const { formatUnits } = ethers.utils; -const formatCapacityResult = ({ productId, availableCapacity, usedCapacity, minAnnualPrice, maxAnnualPrice }) => ({ - productId, - availableCapacity: availableCapacity.map(({ assetId, amount, asset }) => ({ +const formatCapacityResult = capacity => ({ + productId: capacity.productId, + availableCapacity: capacity.availableCapacity.map(({ assetId, amount, asset }) => ({ assetId, amount: amount.toString(), asset, })), - allocatedNxm: usedCapacity.toString(), - minAnnualPrice: formatUnits(minAnnualPrice), - maxAnnualPrice: formatUnits(maxAnnualPrice), + allocatedNxm: capacity.usedCapacity.toString(), + utilizationRate: capacity.utilizationRate.toNumber(), + minAnnualPrice: formatUnits(capacity.minAnnualPrice), + maxAnnualPrice: formatUnits(capacity.maxAnnualPrice), }); /** @@ -34,68 +35,31 @@ const formatCapacityResult = ({ productId, availableCapacity, usedCapacity, minA * schema: * type: array * items: - * type: object - * properties: - * productId: - * type: integer - * description: The product id - * availableCapacity: - * type: array - * description: The maximum available capacity for the product. - * The max amount of cover a user can buy for the product. - * items: - * type: object - * properties: - * assetId: - * type: integer - * description: The asset id - * amount: - * type: string - * format: integer - * description: The capacity amount expressed in the asset - * asset: - * type: object - * description: An object containing asset info - * properties: - * id: - * type: integer - * description: The id of the asset - * symbol: - * type: string - * description: The symbol of the asset - * decimals: - * type: integer - * description: The decimals of the asset - * example: 18 - * allocatedNxm: - * type: string - * format: integer - * description: The used capacity amount for active covers on the product. - * The amount of capacity locked for active covers on the product. - * minAnnualPrice: - * type: string - * description: The minimal annual price is a percentage value between 0-1. - * It depends on the period query param value (default 30 days). - * The cover price starts from this value depending on the requested period and amount. - * maxAnnualPrice: - * type: string - * description: The maximal annual price is a percentage value between 0-1. - * It depends on the period query param value (default 30 days). - * The cover price starts from this value depending on the requested period and amount. + * $ref: '#/components/schemas/CapacityResult' + * 400: + * description: Invalid period + * 500: + * description: Internal Server Error */ router.get( '/capacity', asyncRoute(async (req, res) => { - const store = req.app.get('store'); - const now = BigNumber.from(Date.now()).div(1000); - const period = BigNumber.from(req.query.period || 30); + const periodQuery = Number(req.query.period) || 30; - if (period.lt(28) || period.gt(365)) { - return res.status(400).send({ error: 'Invalid period', response: null }); + if (!Number.isInteger(periodQuery) || periodQuery < 28 || periodQuery > 365) { + return res.status(400).send({ error: 'Invalid period: must be an integer between 28 and 365', response: null }); } - const response = capacityEngine(store, [], now, period); - res.json(response.map(capacity => formatCapacityResult(capacity))); + try { + const period = BigNumber.from(periodQuery); + const store = req.app.get('store'); + const response = capacityEngine(store, { period }); + + res.json(response.map(capacity => formatCapacityResult(capacity))); + } catch (error) { + console.error(error); + return res.status(500).send({ error: 'Internal Server Error', response: null }); + } }), ); @@ -109,7 +73,7 @@ router.get( * parameters: * - in: path * name: productId - * required: false + * required: true * schema: * type: integer * description: The product id @@ -119,75 +83,237 @@ router.get( * content: * application/json: * schema: - * type: object - * properties: - * productId: - * type: integer - * description: The product id - * availableCapacity: - * type: array - * description: The maximum available capacity for the product. - * The max amount of cover a user can buy for the product. - * items: - * type: object - * properties: - * assetId: - * type: integer - * description: The asset id - * amount: - * type: string - * format: integer - * description: The capacity amount expressed in the the asset - * asset: - * type: object - * description: An object containing asset info - * properties: - * id: - * type: integer - * description: The id of the asset - * symbol: - * type: string - * description: The symbol of the asset - * decimals: - * type: integer - * description: The decimals of the asset - * example: 18 - * allocatedNxm: - * type: string - * description: The used capacity amount for active covers on the product. - * The amount of capacity locked for active covers on the product. - * minAnnualPrice: - * type: string - * description: The minimal annual price is a percentage value (2 decimals). - * It depends on the period query param value (default 30 days). - * The cover price starts from this value depending on the requested period and amount. - * maxAnnualPrice: - * type: string - * description: The maximal annual price is a percentage value (2 decimals). - * It depends on the period query param value (default 30 days). - * The cover price starts from this value depending on the requested period and amount. + * $ref: '#/components/schemas/CapacityResult' + * 400: + * description: Invalid productId or period + * 500: + * description: Internal Server Error */ router.get( '/capacity/:productId', asyncRoute(async (req, res) => { const productId = Number(req.params.productId); - const store = req.app.get('store'); - const now = BigNumber.from(Date.now()).div(1000); - const period = BigNumber.from(req.query.period || 30); + const periodQuery = Number(req.query.period) || 30; - if (period.lt(28) || period.gt(365)) { - return res.status(400).send({ error: 'Invalid period', response: null }); + if (!Number.isInteger(periodQuery) || periodQuery < 28 || periodQuery > 365) { + return res.status(400).send({ error: 'Invalid period: must be an integer between 28 and 365', response: null }); } + if (!Number.isInteger(productId) || productId < 0) { + return res.status(400).send({ error: 'Invalid productId: must be an integer', response: null }); + } + + try { + const period = BigNumber.from(periodQuery); + const store = req.app.get('store'); + const [capacity] = capacityEngine(store, { productIds: [productId], period }); - const [capacity] = capacityEngine(store, [productId], now, period); + if (!capacity) { + return res.status(400).send({ error: 'Invalid Product Id', response: null }); + } - if (!capacity) { - return res.status(400).send({ error: 'Invalid Product Id', response: null }); + res.json(formatCapacityResult(capacity)); + } catch (error) { + console.error(error); + return res.status(500).send({ error: 'Internal Server Error', response: null }); } + }), +); - res.json(formatCapacityResult(capacity)); +/** + * @openapi + * /v2/capacity/pools/{poolId}: + * get: + * tags: + * - Capacity + * description: Get capacity data for all products in a specific pool + * parameters: + * - in: path + * name: poolId + * required: true + * schema: + * type: integer + * description: The pool id + * - in: query + * name: period + * required: false + * schema: + * type: integer + * minimum: 28 + * maximum: 365 + * default: 30 + * description: The period in days + * responses: + * 200: + * description: Returns capacity for all products in the specified pool + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/CapacityResult' + * 400: + * description: Invalid pool id or period + * 404: + * description: Pool not found + * 500: + * description: Internal Server Error + */ +router.get( + '/capacity/pools/:poolId', + asyncRoute(async (req, res) => { + const poolId = Number(req.params.poolId); + const periodQuery = Number(req.query.period) || 30; + + if (!Number.isInteger(periodQuery) || periodQuery < 28 || periodQuery > 365) { + return res.status(400).send({ error: 'Invalid period: must be an integer between 28 and 365', response: null }); + } + if (!Number.isInteger(poolId) || poolId <= 0) { + return res.status(400).send({ error: 'Invalid poolId: must be a positive integer', response: null }); + } + + try { + const period = BigNumber.from(periodQuery); + const store = req.app.get('store'); + const response = capacityEngine(store, { poolId, period }); + + if (response.length === 0) { + return res.status(404).send({ error: 'Pool not found', response: null }); + } + + res.json(response.map(capacity => formatCapacityResult(capacity))); + } catch (error) { + console.error(error); + return res.status(500).send({ error: 'Internal Server Error', response: null }); + } }), ); +/** + * @openapi + * /v2/capacity/pools/{poolId}/products/{productId}: + * get: + * tags: + * - Capacity + * description: Get capacity data for a specific product in a specific pool + * parameters: + * - in: path + * name: poolId + * required: true + * schema: + * type: integer + * description: The pool id + * - in: path + * name: productId + * required: true + * schema: + * type: integer + * description: The product id + * - in: query + * name: period + * required: false + * schema: + * type: integer + * minimum: 28 + * maximum: 365 + * default: 30 + * description: The period in days + * responses: + * 200: + * description: Returns capacity for the specified product in the specified pool + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/CapacityResult' + * 400: + * description: Invalid pool id, product id, or period + * 404: + * description: Product not found in the specified pool + * 500: + * description: Internal Server Error + */ +router.get( + '/capacity/pools/:poolId/products/:productId', + asyncRoute(async (req, res) => { + const poolId = Number(req.params.poolId); + const productId = Number(req.params.productId); + const periodQuery = Number(req.query.period) || 30; + + if (!Number.isInteger(periodQuery) || periodQuery < 28 || periodQuery > 365) { + return res.status(400).send({ error: 'Invalid period: must be an integer between 28 and 365', response: null }); + } + if (!Number.isInteger(poolId) || poolId <= 0) { + return res.status(400).send({ error: 'Invalid poolId: must be a positive integer', response: null }); + } + if (!Number.isInteger(productId) || productId < 0) { + return res.status(400).send({ error: 'Invalid productId: must be an integer', response: null }); + } + try { + const period = BigNumber.from(periodQuery); + const store = req.app.get('store'); + const [capacity] = capacityEngine(store, { poolId, productIds: [productId], period }); + if (!capacity) { + return res.status(404).send({ error: 'Product not found in the specified pool', response: null }); + } + res.json(formatCapacityResult(capacity)); + } catch (error) { + console.error(error); + return res.status(500).send({ error: 'Internal Server Error', response: null }); + } + }), +); + +/** + * @openapi + * components: + * schemas: + * CapacityResult: + * type: object + * properties: + * productId: + * type: integer + * description: The product id + * availableCapacity: + * type: array + * description: The maximum available capacity for the product. + * items: + * type: object + * properties: + * assetId: + * type: integer + * description: The asset id + * amount: + * type: string + * format: integer + * description: The capacity amount expressed in the asset + * asset: + * type: object + * description: An object containing asset info + * properties: + * id: + * type: integer + * description: The id of the asset + * symbol: + * type: string + * description: The symbol of the asset + * decimals: + * type: integer + * description: The decimals of the asset + * example: 18 + * allocatedNxm: + * type: string + * format: integer + * description: The used capacity amount for active covers on the product. + * utilizationRate: + * type: number + * format: integer + * description: The percentage of used capacity to total capacity, expressed as basis points (0-10,000). + * minAnnualPrice: + * type: string + * description: The minimal annual price is a percentage value between 0-1. + * maxAnnualPrice: + * type: string + * description: The maximal annual price is a percentage value between 0-1. + */ + module.exports = router; diff --git a/src/store/selectors.js b/src/store/selectors.js index 814b9f50..48cef597 100644 --- a/src/store/selectors.js +++ b/src/store/selectors.js @@ -8,13 +8,25 @@ const selectProduct = (store, productId) => { return products[productId]; }; -const selectProductPools = (store, productId) => { +/** + * Retrieves the product pools associated with a specific product ID, optionally filtered by a pool ID. + * + * @param {Object} store - The Redux store containing the application state. + * @param {number} productId - The ID of the product for which to retrieve pools. + * @param {number|null} [poolId=null] - The ID of the pool to filter by. + * If not provided, all pools associated with the product are returned. + * @returns {Array} Array of product pool objects associated with the specified product (and pool, if provided). + */ +const selectProductPools = (store, productId, poolId = null) => { const { poolProducts, productPoolIds } = store.getState(); const poolIds = productPoolIds[productId] || []; - return poolIds.map(poolId => { + + if (poolId) { const key = `${productId}_${poolId}`; - return poolProducts[key]; - }); + return poolIds.includes(poolId) ? [poolProducts[key]] : []; + } + + return poolIds.map(id => poolProducts[`${productId}_${id}`]); }; const selectAsset = (store, assetId) => { diff --git a/test/unit/capacityEngine.js b/test/unit/capacityEngine.js index e4f18bec..2e0c4c00 100644 --- a/test/unit/capacityEngine.js +++ b/test/unit/capacityEngine.js @@ -1,53 +1,223 @@ const { expect } = require('chai'); -const { BigNumber } = require('ethers'); +const ethers = require('ethers'); const sinon = require('sinon'); -const { capacities } = require('./responses'); -const capacityEngine = require('../../src/lib/capacityEngine'); +const { capacities, poolProductCapacities } = require('./responses'); +const { capacityEngine, getUtilizationRate } = require('../../src/lib/capacityEngine'); // Import the function to test const { selectAsset } = require('../../src/store/selectors'); const mockStore = require('../mocks/store'); -describe('Capacity Engine tests', () => { - const store = { getState: () => null }; +const { BigNumber } = ethers; +const { parseEther } = ethers.utils; +const { Zero } = ethers.constants; - afterEach(function () { - sinon.restore(); - }); +describe('Capacity Engine tests', function () { + describe('capacityEngine', function () { + const store = { getState: () => null }; + + beforeEach(function () { + sinon.stub(store, 'getState').callsFake(() => mockStore); + }); + + afterEach(function () { + sinon.restore(); + }); + + it('should return capacity for all products when no productIds or poolId are provided', function () { + const response = capacityEngine(store, { period: 30 }); - it('should return capacity for all products', () => { - sinon.stub(store, 'getState').callsFake(() => mockStore); - const now = BigNumber.from(Date.now()).div(1000); - const response = capacityEngine(store, [], now); + expect(response).to.have.lengthOf(Object.keys(mockStore.products).length); - response.forEach((product, i) => { - expect(product.productId).to.be.equal(capacities[i].productId); - product.availableCapacity.forEach(({ assetId, amount, asset }, j) => { - expect(amount.toString()).to.be.equal(capacities[i].availableCapacity[j].amount); + response.forEach((product, i) => { + expect(product.productId).to.be.equal(capacities[i].productId); + expect(product.utilizationRate.toNumber()).to.be.equal(capacities[i].utilizationRate); + + product.availableCapacity.forEach(({ assetId, amount, asset }, j) => { + expect(amount.toString()).to.be.equal(capacities[i].availableCapacity[j].amount); + expect(asset).to.deep.equal(selectAsset(store, assetId)); + }); + }); + }); + + it('should return capacity for 1 product across all pools if productId is provided and poolId is not', function () { + const productId = '0'; + const [product] = capacityEngine(store, { productIds: [productId] }); + + const expectedCapacity = capacities[Number(productId)]; + + expect(product.productId).to.be.equal(expectedCapacity.productId); + expect(product.utilizationRate.toNumber()).to.be.equal(expectedCapacity.utilizationRate); + + product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { + expect(amount.toString()).not.to.be.equal(expectedCapacity.availableCapacity[i]); expect(asset).to.deep.equal(selectAsset(store, assetId)); }); }); - }); - it('should return capacity for one product', () => { - sinon.stub(store, 'getState').callsFake(() => mockStore); - const now = BigNumber.from(Date.now()).div(1000); + it('should return undefined for non-existing product', function () { + const nonExistingProductId = '999'; + const [product] = capacityEngine(store, { productIds: [nonExistingProductId] }); + expect(product).to.be.equal(undefined); + }); - const [product] = capacityEngine(store, ['0'], now); - const [expectedCapacities] = capacities; + it('should return capacity for a specific product and pool if both productId and poolId are provided', function () { + const productId = '0'; + const poolId = 2; + const [product] = capacityEngine(store, { poolId, productIds: [productId] }); - expect(product.productId).to.be.equal(expectedCapacities.productId); - product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { - expect(amount.toString()).not.to.be.equal(expectedCapacities.availableCapacity[i]); - expect(asset).to.deep.equal(selectAsset(store, assetId)); + const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === Number(productId)); + + expect(product.productId).to.equal(Number(productId)); + expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); + expect(product.utilizationRate.toNumber()).to.be.equal(expectedCapacity.utilizationRate); + expect(product.minAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.minAnnualPrice).toString()); + expect(product.maxAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.maxAnnualPrice).toString()); + expect(product.availableCapacity).to.have.lengthOf(expectedCapacity.availableCapacity.length); + + product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { + expect(amount.toString()).to.be.equal(expectedCapacity.availableCapacity[i].amount); + expect(asset).to.deep.equal(selectAsset(store, assetId)); + }); + }); + + it('should return capacities for all products in a specific pool when only poolId is provided', function () { + const poolId = 2; + const response = capacityEngine(store, { poolId }); + + expect(response.length).to.be.greaterThan(0); + + response.forEach(product => { + const expectedCapacity = poolProductCapacities[poolId].find(c => c.productId === product.productId); + const productPools = mockStore.productPoolIds[product.productId]; + expect(productPools).to.include(poolId); + expect(product.usedCapacity.toString()).to.equal(expectedCapacity.allocatedNxm); + expect(product.utilizationRate.toNumber()).to.be.equal(expectedCapacity.utilizationRate); + expect(product.minAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.minAnnualPrice).toString()); + expect(product.maxAnnualPrice.toString()).to.equal(parseEther(expectedCapacity.maxAnnualPrice).toString()); + expect(product.availableCapacity).to.have.lengthOf(expectedCapacity.availableCapacity.length); + + product.availableCapacity.forEach(({ assetId, amount, asset }, i) => { + expect(amount.toString()).to.be.equal(expectedCapacity.availableCapacity[i].amount); + expect(asset).to.deep.equal(selectAsset(store, assetId)); + }); + }); + }); + + it('should return the same total capacity for a product across all pools as when poolId is not given', function () { + const productId = '0'; + const poolIds = mockStore.productPoolIds[productId]; + + // Get capacity for product 0 across all pools + const [allPoolsProduct] = capacityEngine(store, { productIds: [productId] }); + + const initObject = { + productId: Number(productId), + usedCapacity: BigNumber.from(0), + minAnnualPrice: BigNumber.from(0), + maxAnnualPrice: BigNumber.from(0), + availableCapacity: [], + }; + + // Get capacity for product 0 for each pool and sum them up + const summedCapacity = poolIds.reduce((acc, poolId) => { + const [product] = capacityEngine(store, { poolId: Number(poolId), productIds: [productId] }); + + if (!product) { + return acc; + } + + // Sum up all numeric fields + acc.usedCapacity = acc.usedCapacity.add(product.usedCapacity); + acc.minAnnualPrice = acc.minAnnualPrice.gt(product.minAnnualPrice) + ? acc.minAnnualPrice + : product.minAnnualPrice; + acc.maxAnnualPrice = acc.maxAnnualPrice.gt(product.maxAnnualPrice) + ? acc.maxAnnualPrice + : product.maxAnnualPrice; + + // Sum up availableCapacity for each asset + product.availableCapacity.forEach((capacity, index) => { + if (!acc.availableCapacity[index]) { + acc.availableCapacity[index] = { ...capacity, amount: BigNumber.from(0) }; + } + acc.availableCapacity[index].amount = acc.availableCapacity[index].amount.add(capacity.amount); + }); + + return acc; + }, initObject); + + // Assert that all fields match + const capacityAvailableNXM = summedCapacity.availableCapacity.find(c => c.assetId === 255)?.amount; + const utilizationRate = getUtilizationRate(capacityAvailableNXM, summedCapacity.usedCapacity); + expect(summedCapacity.productId).to.equal(allPoolsProduct.productId); + expect(summedCapacity.usedCapacity.toString()).to.equal(allPoolsProduct.usedCapacity.toString()); + expect(utilizationRate.toNumber()).to.be.equal(allPoolsProduct.utilizationRate.toNumber()); + expect(summedCapacity.minAnnualPrice.toString()).to.equal(allPoolsProduct.minAnnualPrice.toString()); + expect(summedCapacity.maxAnnualPrice.toString()).to.equal(allPoolsProduct.maxAnnualPrice.toString()); + + // Assert that availableCapacity matches for each asset + expect(summedCapacity.availableCapacity.length).to.equal(allPoolsProduct.availableCapacity.length); + summedCapacity.availableCapacity.forEach((capacity, index) => { + expect(capacity.amount.toString()).to.equal(allPoolsProduct.availableCapacity[index].amount.toString()); + expect(capacity.assetId).to.equal(allPoolsProduct.availableCapacity[index].assetId); + expect(capacity.asset).to.deep.equal(allPoolsProduct.availableCapacity[index].asset); + }); + }); + + it('should handle products with fixed price correctly', function () { + const fixedPricedProductId = '2'; + const [product] = capacityEngine(store, { productIds: [fixedPricedProductId] }); + + expect(product.productId).to.equal(Number(fixedPricedProductId)); + expect(product.minAnnualPrice).to.deep.equal(product.maxAnnualPrice); + }); + + it('should handle products without fixed price correctly', function () { + const nonFixedPricedProductId = '0'; + const [product] = capacityEngine(store, { productIds: [nonFixedPricedProductId] }); + + expect(product.productId).to.equal(Number(nonFixedPricedProductId)); + expect(product.minAnnualPrice).to.not.deep.equal(product.maxAnnualPrice); }); }); - it('should throw non existing product', () => { - sinon.stub(store, 'getState').callsFake(() => mockStore); - const now = BigNumber.from(Date.now()).div(1000); + describe('getUtilizationRate tests', function () { + it('should calculate utilization rate correctly when there is available capacity', function () { + const capacityAvailableNXM = parseEther('100'); + const capacityUsedNXM = parseEther('50'); - const [product] = capacityEngine(store, ['999'], now); + const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); - expect(product).to.be.equal(undefined); + const expectedRate = BigNumber.from(3333); // (50 / (100 + 50)) * 10000 = 3333 basis points + + expect(utilizationRate.toNumber()).to.be.closeTo(expectedRate.toNumber(), 1); + }); + + it('should return 1 when ALL capacity is used (no available capacity)', function () { + const capacityAvailableNXM = Zero; + const capacityUsedNXM = parseEther('150'); + + const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + + expect(utilizationRate.toNumber()).to.equal(10000); + }); + + it('should return undefined if utilizationRate cannot be calculated because of missing data', function () { + const capacityAvailableNXM = undefined; + const capacityUsedNXM = parseEther('50'); + + const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + + expect(utilizationRate).to.equal(undefined); + }); + + it('should return undefined when there is no capacity available', function () { + const capacityAvailableNXM = Zero; + const capacityUsedNXM = Zero; + + const utilizationRate = getUtilizationRate(capacityAvailableNXM, capacityUsedNXM); + + expect(utilizationRate.toNumber()).to.equal(0); + }); }); }); diff --git a/test/unit/responses.js b/test/unit/responses.js index 66c9dfe9..21e5ab65 100644 --- a/test/unit/responses.js +++ b/test/unit/responses.js @@ -7,6 +7,7 @@ const assets = { 255: { id: 255, symbol: 'NXM', decimals: 18 }, }; +// capacities response for product across ALL pools const capacities = [ { productId: 0, @@ -40,6 +41,7 @@ const capacities = [ asset: assets[255], }, ], + utilizationRate: 4405, }, { productId: 1, @@ -73,6 +75,7 @@ const capacities = [ asset: assets[255], }, ], + utilizationRate: 0, }, { productId: 2, @@ -106,6 +109,7 @@ const capacities = [ asset: assets[255], }, ], + utilizationRate: 0, }, { productId: 3, @@ -139,6 +143,7 @@ const capacities = [ allocatedNxm: '32725200000000000000000', minAnnualPrice: '0.0775', maxAnnualPrice: '0.104190714614767679', + utilizationRate: 3407, }, { productId: 4, @@ -172,9 +177,119 @@ const capacities = [ allocatedNxm: '20004610000000000000000', maxAnnualPrice: '0.077089706487431343', minAnnualPrice: '0.02', + utilizationRate: 8467, }, ]; +// capacities response for product by pool +const poolProductCapacities = { + // poolId 2 + 2: [ + { + productId: 0, + availableCapacity: [ + { + assetId: 0, + amount: '3750158703091230720', + asset: { id: 0, symbol: 'ETH', decimals: 18 }, + }, + { + assetId: 1, + amount: '10478675352241508148979', + asset: { id: 1, symbol: 'DAI', decimals: 18 }, + }, + { + assetId: 6, + amount: '10478675347', + asset: { id: 6, symbol: 'USDC', decimals: 6 }, + }, + { + assetId: 7, + amount: '30013555', + asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, + }, + { + assetId: 255, + amount: '364800000000000000000', + asset: { id: 255, symbol: 'NXM', decimals: 18 }, + }, + ], + allocatedNxm: '0', + minAnnualPrice: '0.02', + maxAnnualPrice: '0.03', + utilizationRate: 0, + }, + { + productId: 1, + availableCapacity: [ + { + assetId: 0, + amount: '3750158703091230720', + asset: { id: 0, symbol: 'ETH', decimals: 18 }, + }, + { + assetId: 1, + amount: '10478675352241508148979', + asset: { id: 1, symbol: 'DAI', decimals: 18 }, + }, + { + assetId: 6, + amount: '10478675347', + asset: { id: 6, symbol: 'USDC', decimals: 6 }, + }, + { + assetId: 7, + amount: '30013555', + asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, + }, + { + assetId: 255, + amount: '364800000000000000000', + asset: { id: 255, symbol: 'NXM', decimals: 18 }, + }, + ], + allocatedNxm: '0', + minAnnualPrice: '0.02', + maxAnnualPrice: '0.03', + utilizationRate: 0, + }, + { + productId: 2, + availableCapacity: [ + { + assetId: 0, + amount: '3750158703091230720', + asset: { id: 0, symbol: 'ETH', decimals: 18 }, + }, + { + assetId: 1, + amount: '10478675352241508148979', + asset: { id: 1, symbol: 'DAI', decimals: 18 }, + }, + { + assetId: 6, + amount: '10478675347', + asset: { id: 6, symbol: 'USDC', decimals: 6 }, + }, + { + assetId: 7, + amount: '30013555', + asset: { id: 7, symbol: 'cbBTC', decimals: 8 }, + }, + { + assetId: 255, + amount: '364800000000000000000', + asset: { id: 255, symbol: 'NXM', decimals: 18 }, + }, + ], + allocatedNxm: '0', + minAnnualPrice: '0.02', + maxAnnualPrice: '0.02', + utilizationRate: 0, + }, + ], +}; + const ethQuote = { annualPrice: '199', premiumInNXM: '194600000000000000', @@ -263,6 +378,8 @@ const getQuote = assetId => ({ }); module.exports = { + assets, capacities, + poolProductCapacities, getQuote, }; diff --git a/test/unit/routes/capacity.js b/test/unit/routes/capacity.js index e3d8b910..87c4a636 100644 --- a/test/unit/routes/capacity.js +++ b/test/unit/routes/capacity.js @@ -2,31 +2,132 @@ const { expect } = require('chai'); const supertest = require('supertest'); const initApp = require('../../mocks/server'); -const { capacities } = require('../responses'); +const { capacities, poolProductCapacities } = require('../responses'); -describe('GET /capacity', () => { +describe('Capacity Routes', () => { let server; - before(() => { + let selectProductStub; + + beforeEach(() => { const app = initApp(); server = supertest(app); }); - it('should get all capacities for products', async function () { - const { body: response } = await server.get('/v2/capacity').expect('Content-Type', /json/).expect(200); - expect(response).to.be.deep.equal(capacities); + afterEach(() => { + if (selectProductStub) { + selectProductStub.restore(); + } + }); + + describe('GET /capacity', () => { + it('should get all capacities for products', async function () { + const url = '/v2/capacity'; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(200); + expect(response).to.be.deep.equal(capacities); + }); + + it('should return 400 Invalid period', async function () { + const invalidPeriod = 10; + const url = `/v2/capacity?period=${invalidPeriod}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid period: must be an integer between 28 and 365'); + }); }); - it('should get all capacities for one product', async function () { - const productId = 0; - const { body: response } = await server.get(`/v2/capacity/${productId}`).expect('Content-Type', /json/).expect(200); - expect(response).to.be.deep.equal(capacities[0]); + describe('GET /capacity/:productId', () => { + it('should get all capacities for one product', async function () { + const productId = 0; + const url = `/v2/capacity/${productId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(200); + expect(response).to.be.deep.equal(capacities[0]); + }); + + it('should return 400 Invalid Product Id for non-existent productId', async function () { + const nonExistentProductId = 999; + const url = `/v2/capacity/${nonExistentProductId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid Product Id'); + }); + + it('should return 400 Invalid productId if the productId is not an integer', async function () { + const invalidProductId = -1; + const url = `/v2/capacity/${invalidProductId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid productId: must be an integer'); + }); }); - it('should throw an error if product is non-existant', async function () { - const productId = 5; - const { - body: { error }, - } = await server.get(`/v2/capacity/${productId}`).expect('Content-Type', /json/).expect(400); - expect(error).to.be.equal('Invalid Product Id'); + describe('GET /capacity/pools/:poolId', () => { + it('should get all capacities for all products in a specific pool', async function () { + const poolId = 2; + const url = `/v2/capacity/pools/${poolId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(200); + expect(response).to.be.deep.equal(poolProductCapacities[poolId]); + }); + + it('should return 400 Invalid poolId', async function () { + const invalidPoolId = 0; + const url = `/v2/capacity/pools/${invalidPoolId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid poolId: must be a positive integer'); + }); + + it('should return 400 Invalid period', async function () { + const invalidPeriod = 10; + const poolId = 2; + const url = `/v2/capacity/pools/${poolId}?period=${invalidPeriod}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid period: must be an integer between 28 and 365'); + }); + + it('should return 404 Pool not found', async function () { + const nonExistentPoolId = 999; + const url = `/v2/capacity/pools/${nonExistentPoolId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(404); + expect(response.error).to.be.equal('Pool not found'); + }); + }); + + describe('GET /capacity/pools/:poolId/products/:productId', () => { + it('should get capacity for a specific product in a specific pool', async function () { + const poolId = 2; + const productId = 0; + const url = `/v2/capacity/pools/${poolId}/products/${productId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(200); + expect(response).to.be.deep.equal(poolProductCapacities[poolId][productId]); + }); + + it('should return 400 Invalid productId', async function () { + const poolId = 2; + const invalidProductId = -1; + const url = `/v2/capacity/pools/${poolId}/products/${invalidProductId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid productId: must be an integer'); + }); + + it('should return 400 Invalid poolId', async function () { + const invalidPoolId = 0; + const productId = 0; + const url = `/v2/capacity/pools/${invalidPoolId}/products/${productId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid poolId: must be a positive integer'); + }); + + it('should return 400 Invalid period', async function () { + const invalidPeriod = 10; + const poolId = 2; + const productId = 0; + const url = `/v2/capacity/pools/${poolId}/products/${productId}?period=${invalidPeriod}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(400); + expect(response.error).to.be.equal('Invalid period: must be an integer between 28 and 365'); + }); + + it('should return 404 Product not found in the specified pool', async function () { + const poolId = 2; + const nonExistentProductId = 999; + const url = `/v2/capacity/pools/${poolId}/products/${nonExistentProductId}`; + const { body: response } = await server.get(url).expect('Content-Type', /json/).expect(404); + expect(response.error).to.be.equal('Product not found in the specified pool'); + }); }); }); diff --git a/test/unit/selectors.js b/test/unit/selectors.js new file mode 100644 index 00000000..d1ab6c6a --- /dev/null +++ b/test/unit/selectors.js @@ -0,0 +1,53 @@ +const { expect } = require('chai'); + +const { selectProductPools } = require('../../src/store/selectors'); +const mockStore = require('../mocks/store'); + +describe('selectProductPools', function () { + let store; + + before(function () { + store = { getState: () => mockStore }; + }); + + it('should return an empty array when productId is not found', function () { + const nonExistentProductId = 999; + const result = selectProductPools(store, nonExistentProductId); + + expect(result).to.have.lengthOf(0); + }); + + it('should return an empty array when poolId is provided but not found', function () { + const productId = 0; + const nonExistentPoolId = 999; + const result = selectProductPools(store, productId, nonExistentPoolId); + + expect(result).to.have.lengthOf(0); + }); + + it('should return a specific pool for a product when poolId is provided', function () { + const productId = 0; + const existingPoolId = 2; + const [poolProduct] = selectProductPools(store, productId, existingPoolId); + + expect(poolProduct).to.deep.equal(mockStore.poolProducts['0_2']); + }); + + it('should return all pools for a product when poolId is not provided', function () { + const productId = 0; + const [poolProduct1, poolProduct2, poolProduct3] = selectProductPools(store, productId); + + expect(poolProduct1).to.deep.equal(mockStore.poolProducts['0_1']); + expect(poolProduct2).to.deep.equal(mockStore.poolProducts['0_2']); + expect(poolProduct3).to.deep.equal(mockStore.poolProducts['0_3']); + }); + + it('should return pools in the correct custom priority order for specific products', function () { + const productIdWithCustomPoolOrder = 4; + const result = selectProductPools(store, productIdWithCustomPoolOrder); + + expect(result).to.have.lengthOf(3); + const expectedPoolIds = mockStore.productPoolIds[productIdWithCustomPoolOrder]; + expect(result.map(pool => pool.poolId)).to.deep.equal(expectedPoolIds); + }); +});