diff --git a/.github/workflows/monnifytest.yml b/.github/workflows/monnifytest.yml index c593d38..ded6f8c 100644 --- a/.github/workflows/monnifytest.yml +++ b/.github/workflows/monnifytest.yml @@ -13,6 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: + max-parallel: 1 matrix: node-version: [18.x, 20.x, 22.x, 24.x] @@ -35,6 +36,7 @@ jobs: - name: Run tests with coverage env: NODE_ENV: sandbox + MONNIFY_ENV: SANDBOX BASEURL: https://sandbox.monnify.com CONTRACT: ${{ secrets.MONNIFY_CONTRACT_CODE || '5867418298' }} MONNIFY_APIKEY: ${{ secrets.MONNIFY_APIKEY || 'MK_TEST_GC3B8XG2XX' }} @@ -42,12 +44,19 @@ jobs: WALLETACCOUNTNUMBER: ${{ secrets.MONNIFY_WALLET_ACCOUNT || '3934178936' }} TOKENEXPIRATIONTHRESHOLD: 500 TOKENFILE: Cache - run: npm run test:coverage + # c8 coverage instrumentation has a known incompatibility with Node 24's V8. + # Only run c8 on Node 22 (which also uploads to Codecov); other versions use plain mocha. + run: | + if [ "${{ matrix.node-version }}" = "22.x" ]; then + npm run test:coverage + else + npm test + fi - name: Upload coverage to Codecov # Only upload once — no point sending the same report from all 4 Node versions if: matrix.node-version == '22.x' - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info diff --git a/README.md b/README.md index 045df80..b3566b6 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ You need three things from your Monnify dashboard: | Variable | Where to find it | |---|---| +| `MONNIFY_ENV` | `SANDBOX` for testing, `LIVE` for production | | `MONNIFY_APIKEY` | Dashboard → Settings → API Keys | | `MONNIFY_SECRET` | Dashboard → Settings → API Keys | | `CONTRACT` | Dashboard → Settings → Contract Code | @@ -64,12 +65,15 @@ The recommended approach is a `.env` file so credentials never appear in source ```bash # .env +MONNIFY_ENV=SANDBOX MONNIFY_APIKEY=MK_TEST_XXXXXXXXXX MONNIFY_SECRET=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX CONTRACT=1234567890 WALLETACCOUNTNUMBER=0123456789 # only needed for disbursement wallet balance ``` +> The library reads `MONNIFY_ENV` automatically — no need to pass it to any constructor. It will also detect if your API key prefix does not match your declared environment (e.g. a `MK_PROD_` key with `MONNIFY_ENV=SANDBOX`) and throw a clear error. + Then load it at the top of your app: ```js @@ -88,7 +92,7 @@ Every Monnify API call needs a **bearer token**. The library handles token fetch ```js import { Transaction } from 'monnify-nodejs-lib'; -const api = new Transaction('SANDBOX'); // or 'LIVE' +const api = new Transaction(); const [statusCode, token] = await api.getToken(); // token is the raw bearer string — pass it to every method call below @@ -96,7 +100,7 @@ const [statusCode, token] = await api.getToken(); The token is cached in memory and reused until it expires, so calling `getToken()` multiple times is cheap. -> The environment value must be `'SANDBOX'` or `'LIVE'` (uppercase). All instances in the same process must use the same environment. +> The environment is read from `MONNIFY_ENV` in your environment variables. All instances in the same process must use the same environment. --- @@ -128,7 +132,6 @@ import { MonnifyAPI } from 'monnify-nodejs-lib'; const monnify = new MonnifyAPI({ MONNIFY_APIKEY: process.env.MONNIFY_APIKEY, MONNIFY_SECRET: process.env.MONNIFY_SECRET, - env: 'SANDBOX' }); const [, token] = await monnify.getToken(); @@ -149,7 +152,7 @@ A reserved account is a **dedicated virtual bank account** assigned to a specifi ```js import { ReservedAccount } from 'monnify-nodejs-lib'; -const api = new ReservedAccount('SANDBOX'); +const api = new ReservedAccount(); const [, token] = await api.getToken(); ``` @@ -196,7 +199,7 @@ Manage the full payment lifecycle — from initialising a checkout to charging a ```js import { Transaction } from 'monnify-nodejs-lib'; -const api = new Transaction('SANDBOX'); +const api = new Transaction(); const [, token] = await api.getToken(); ``` @@ -215,6 +218,13 @@ const [, token] = await api.getToken(); | `ThreeDsSecureAuthTransaction(token, data)` | Complete a 3DS card authentication | | `cardTokenization(token, data)` | Charge a previously saved card token | +> **`from` / `to` date filters** — pass Unix timestamps in **milliseconds** (not ISO strings). +> ```js +> const to = Date.now(); +> const from = to - 7 * 24 * 60 * 60 * 1000; // 7 days ago +> const [, resp] = await api.getAllTransactions(token, { from, to, page: 0, size: 20 }); +> ``` + ### Example — Basic Payment Flow ```js @@ -302,7 +312,7 @@ Split incoming payments automatically across multiple bank accounts. ```js import { SubAccount } from 'monnify-nodejs-lib'; -const api = new SubAccount('SANDBOX'); +const api = new SubAccount(); const [, token] = await api.getToken(); ``` @@ -336,7 +346,7 @@ Create payment invoices with an expiry date and track their status. ```js import { Invoice } from 'monnify-nodejs-lib'; -const api = new Invoice('SANDBOX'); +const api = new Invoice(); const [, token] = await api.getToken(); ``` @@ -377,7 +387,7 @@ Query how funds from transactions have been settled to your bank account. ```js import { Settlement } from 'monnify-nodejs-lib'; -const api = new Settlement('SANDBOX'); +const api = new Settlement(); const [, token] = await api.getToken(); ``` @@ -411,7 +421,7 @@ Send money out of your Monnify wallet — single transfers, bulk transfers, and ```js import { Disbursement } from 'monnify-nodejs-lib'; -const api = new Disbursement('SANDBOX'); +const api = new Disbursement(); const [, token] = await api.getToken(); ``` @@ -459,7 +469,7 @@ Reverse a payment back to the customer's original payment method. ```js import { TransactionRefund } from 'monnify-nodejs-lib'; -const api = new TransactionRefund('SANDBOX'); +const api = new TransactionRefund(); const [, token] = await api.getToken(); ``` @@ -496,7 +506,7 @@ Check the available balance in your Monnify disbursement wallet. ```js import { Wallet } from 'monnify-nodejs-lib'; -const api = new Wallet('SANDBOX'); +const api = new Wallet(); const [, token] = await api.getToken(); ``` @@ -526,7 +536,7 @@ Create and manage transaction limit profiles, then attach them to reserved accou ```js import { LimitProfile } from 'monnify-nodejs-lib'; -const api = new LimitProfile('SANDBOX'); +const api = new LimitProfile(); const [, token] = await api.getToken(); ``` @@ -563,7 +573,7 @@ Set up recurring debit mandates — charge a customer's bank account on a schedu ```js import { DirectDebit } from 'monnify-nodejs-lib'; -const api = new DirectDebit('SANDBOX'); +const api = new DirectDebit(); const [, token] = await api.getToken(); ``` @@ -631,7 +641,7 @@ getBillerCategories() → pick a category (e.g. "CABLE_TV") ```js import { BillsPayment } from 'monnify-nodejs-lib'; -const api = new BillsPayment('SANDBOX'); +const api = new BillsPayment(); const [, token] = await api.getToken(); ``` @@ -701,7 +711,7 @@ Validate bank accounts and verify customer identity documents. ```js import { Verification } from 'monnify-nodejs-lib'; -const api = new Verification('SANDBOX'); +const api = new Verification(); const [, token] = await api.getToken(); ``` diff --git a/index.js b/index.js index 38c0ec4..82c701d 100644 --- a/index.js +++ b/index.js @@ -19,28 +19,35 @@ import { BillsPayment } from "./src/valueAddedService/billsPayment.js"; export class MonnifyAPI extends BaseRequestAPI { - constructor(config) { - process.env.MONNIFY_APIKEY = config.MONNIFY_APIKEY; - process.env.MONNIFY_SECRET = config.MONNIFY_SECRET; - super(config.env); + constructor(config = {}) { + if (config.MONNIFY_APIKEY) process.env.MONNIFY_APIKEY = config.MONNIFY_APIKEY; + if (config.MONNIFY_SECRET) process.env.MONNIFY_SECRET = config.MONNIFY_SECRET; + if (config.env && !process.env.MONNIFY_ENV) { + console.warn( + `[monnify] Passing env in the MonnifyAPI config is deprecated and will be removed in a future version. ` + + `Add MONNIFY_ENV=${config.env} to your .env file instead.` + ); + process.env.MONNIFY_ENV = config.env; + } + super(); // ── Collections ────────────────────────────────────────────────────── - this.reservedAccount = new ReservedAccount(config.env); - this.transaction = new Transaction(config.env); - this.subAccount = new SubAccount(config.env); - this.invoice = new Invoice(config.env); - this.settlement = new Settlement(config.env); - this.limitProfile = new LimitProfile(config.env); - this.directDebit = new DirectDebit(config.env); + this.reservedAccount = new ReservedAccount(); + this.transaction = new Transaction(); + this.subAccount = new SubAccount(); + this.invoice = new Invoice(); + this.settlement = new Settlement(); + this.limitProfile = new LimitProfile(); + this.directDebit = new DirectDebit(); // ── Disbursements ──────────────────────────────────────────────────── - this.disbursement = new Disbursement(config.env); - this.refund = new TransactionRefund(config.env); - this.wallet = new Wallet(config.env); + this.disbursement = new Disbursement(); + this.refund = new TransactionRefund(); + this.wallet = new Wallet(); // ── Value-added services ───────────────────────────────────────────── - this.verification = new Verification(config.env); - this.billsPayment = new BillsPayment(config.env); + this.verification = new Verification(); + this.billsPayment = new BillsPayment(); } } diff --git a/package.json b/package.json index fd87322..b364336 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,10 @@ ".": "./index.js" }, "scripts": { - "test": "mocha --timeout 15000 tests/index.test.js tests/collection.test.js tests/refund.test.js tests/disbursement.test.js tests/subaccount.test.js tests/verification.test.js tests/wallet.test.js tests/invoice.test.js tests/settlement.test.js tests/billsPayment.test.js tests/limitProfile.test.js tests/directDebit.test.js tests/reservedAccount.test.js", - "test:coverage": "c8 --reporter=lcov --reporter=text mocha --timeout 15000 tests/index.test.js tests/collection.test.js tests/refund.test.js tests/disbursement.test.js tests/subaccount.test.js tests/verification.test.js tests/wallet.test.js tests/invoice.test.js tests/settlement.test.js tests/billsPayment.test.js tests/limitProfile.test.js tests/directDebit.test.js tests/reservedAccount.test.js", - "test:core": "mocha --timeout 15000 tests/collection.test.js tests/refund.test.js tests/disbursement.test.js tests/subaccount.test.js tests/verification.test.js", - "test:new": "mocha --timeout 15000 tests/wallet.test.js tests/invoice.test.js tests/settlement.test.js tests/billsPayment.test.js tests/limitProfile.test.js tests/directDebit.test.js" + "test": "MONNIFY_ENV=SANDBOX mocha --timeout 15000 tests/index.test.js tests/collection.test.js tests/refund.test.js tests/disbursement.test.js tests/subaccount.test.js tests/verification.test.js tests/wallet.test.js tests/invoice.test.js tests/settlement.test.js tests/billsPayment.test.js tests/limitProfile.test.js tests/directDebit.test.js tests/reservedAccount.test.js tests/env-validation.test.js", + "test:coverage": "MONNIFY_ENV=SANDBOX c8 --reporter=lcov --reporter=text mocha --timeout 15000 tests/index.test.js tests/collection.test.js tests/refund.test.js tests/disbursement.test.js tests/subaccount.test.js tests/verification.test.js tests/wallet.test.js tests/invoice.test.js tests/settlement.test.js tests/billsPayment.test.js tests/limitProfile.test.js tests/directDebit.test.js tests/reservedAccount.test.js tests/env-validation.test.js", + "test:core": "MONNIFY_ENV=SANDBOX mocha --timeout 15000 tests/collection.test.js tests/refund.test.js tests/disbursement.test.js tests/subaccount.test.js tests/verification.test.js", + "test:new": "MONNIFY_ENV=SANDBOX mocha --timeout 15000 tests/wallet.test.js tests/invoice.test.js tests/settlement.test.js tests/billsPayment.test.js tests/limitProfile.test.js tests/directDebit.test.js" }, "repository": { "type": "git", diff --git a/src/base_api.js b/src/base_api.js index 0f71273..265152c 100644 --- a/src/base_api.js +++ b/src/base_api.js @@ -22,21 +22,56 @@ let _lockedEnvironment = null; export class BaseRequestAPI { constructor(environment) { - if (_lockedEnvironment && _lockedEnvironment !== environment) { + const envFromProcess = process.env.MONNIFY_ENV?.trim().toUpperCase(); + const envFromArg = typeof environment === 'string' ? environment.trim().toUpperCase() : undefined; + + let resolvedEnv; + if (envFromProcess) { + resolvedEnv = envFromProcess; + } else if (envFromArg) { + console.warn( + `[monnify] Passing the environment to the constructor is deprecated and will be removed in a future version. ` + + `Add MONNIFY_ENV=${envFromArg} to your .env file instead.` + ); + resolvedEnv = envFromArg; + } else { throw new Error( - `Environment conflict: already initialised as "${_lockedEnvironment}". ` + - `Cannot create a "${environment}" instance in the same runtime.` + `MONNIFY_ENV is not set. Add MONNIFY_ENV=SANDBOX or MONNIFY_ENV=LIVE to your environment variables.` + ); + } + + if (!['SANDBOX', 'LIVE'].includes(resolvedEnv)) { + throw new Error( + `Invalid environment "${resolvedEnv}". Must be "SANDBOX" or "LIVE".` ); } - if (!['SANDBOX', 'LIVE'].includes(environment)) { + + if (_lockedEnvironment && _lockedEnvironment !== resolvedEnv) { throw new Error( - `Unknown environment "${environment}". Specify "SANDBOX" or "LIVE".` + `Environment conflict: already initialised as "${_lockedEnvironment}". ` + + `Cannot create a "${resolvedEnv}" instance in the same runtime.` ); } - _lockedEnvironment = environment; - this.environment = environment; - this.baseUrl = environment === 'SANDBOX' + const apiKey = process.env.MONNIFY_APIKEY; + if (apiKey) { + if (resolvedEnv === 'SANDBOX' && apiKey.startsWith('MK_PROD_')) { + throw new Error( + `Environment mismatch: MONNIFY_ENV is "SANDBOX" but your API key starts with "MK_PROD_". ` + + `Use your sandbox key (starts with "MK_TEST_") or set MONNIFY_ENV=LIVE.` + ); + } + if (resolvedEnv === 'LIVE' && apiKey.startsWith('MK_TEST_')) { + throw new Error( + `Environment mismatch: MONNIFY_ENV is "LIVE" but your API key starts with "MK_TEST_". ` + + `Use your live key (starts with "MK_PROD_") or set MONNIFY_ENV=SANDBOX.` + ); + } + } + + _lockedEnvironment = resolvedEnv; + this.environment = resolvedEnv; + this.baseUrl = resolvedEnv === 'SANDBOX' ? 'https://sandbox.monnify.com' : 'https://api.monnify.com'; this.apiKey = process.env.MONNIFY_APIKEY; diff --git a/tests/base.test.js b/tests/base.test.js index 84b28fc..62f1e3c 100644 --- a/tests/base.test.js +++ b/tests/base.test.js @@ -5,7 +5,7 @@ import { BaseRequestAPI } from "../src/base_api.js"; let instance beforeEach(async () =>{ - instance = new BaseRequestAPI('SANDBOX') + instance = new BaseRequestAPI() }) diff --git a/tests/billsPayment.test.js b/tests/billsPayment.test.js index bc4963f..0dace31 100644 --- a/tests/billsPayment.test.js +++ b/tests/billsPayment.test.js @@ -150,6 +150,13 @@ describe("Bills Payment API Tests", () => { /reference/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.vendBill(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe("requeryBillPayment", () => { @@ -170,6 +177,64 @@ describe("Bills Payment API Tests", () => { /reference/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.requeryBillPayment(token[1]), + /Method requires exactly two parameters/ + ); + }); + }); + +}); + + +// ── Additional argument-guard and validation coverage ──────────────────────── + +describe("BillsPayment — getBillerCategories guards", () => { + it("should throw when called with no arguments", async () => { + await assert.rejects( + async () => await instance.getBillerCategories(), + /Method requires at least one parameter/ + ); + }); + it("should throw when size is an invalid type", async () => { + await assert.rejects( + () => instance.getBillerCategories(token[1], { size: "invalid" }), + /size/ + ); + }); +}); + +describe("BillsPayment — listBillers guards", () => { + it("should throw when called with no arguments", async () => { + await assert.rejects( + async () => await instance.listBillers(), + /Method requires at least one parameter/ + ); }); + it("should throw when size is an invalid type", async () => { + await assert.rejects( + () => instance.listBillers(token[1], { size: "invalid" }), + /size/ + ); + }); +}); + +describe("BillsPayment — getBillerProducts without-data guard", () => { + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.getBillerProducts(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); +describe("BillsPayment — validateCustomer without-data guard", () => { + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.validateCustomer(token[1]), + /Method requires exactly two parameters/ + ); + }); }); diff --git a/tests/collection.test.js b/tests/collection.test.js index c221daf..209337a 100644 --- a/tests/collection.test.js +++ b/tests/collection.test.js @@ -181,6 +181,27 @@ describe('Check Get All Transactions', () => { const [rCode] = await instance.getAllTransactions(token[1]); assert.strictEqual(rCode, 200); }); + + it('should accept from/to as Unix millisecond timestamps', async () => { + const to = Date.now(); + const from = to - 86400000; // 24 hours ago + const [rCode] = await instance.getAllTransactions(token[1], { from, to, page: 0, size: 5 }); + assert.strictEqual(rCode, 200); + }); + + it('should throw when from is a date string instead of a Unix timestamp', async () => { + await assert.rejects( + () => instance.getAllTransactions(token[1], { from: '2025-01-01T00:00:00.000Z' }), + /from/ + ); + }); + + it('should throw when to is a date string instead of a Unix timestamp', async () => { + await assert.rejects( + () => instance.getAllTransactions(token[1], { to: '2025-12-31T23:59:59.999Z' }), + /to/ + ); + }); }); @@ -272,6 +293,13 @@ describe('Check 3DS Secure Auth Transaction', () => { /apiKey/ ); }); + + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.ThreeDsSecureAuthTransaction(token[1]), + /Method requires exactly two parameters/ + ); + }); }); @@ -322,4 +350,191 @@ describe('Check Card Tokenization', () => { /customerEmail/ ); }); + + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.cardTokenization(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + + +// ── Additional validation coverage ─────────────────────────────────────────── + +describe('Transaction — initTransaction validation', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.initTransaction(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('should throw when required fields are missing', async () => { + await assert.rejects( + () => instance.initTransaction(token[1], {}), + /customerName|customerEmail|amount/ + ); + }); +}); + +describe('Transaction — getTransactionStatusv2 validation', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.getTransactionStatusv2(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('should throw when transactionReference is missing', async () => { + await assert.rejects( + () => instance.getTransactionStatusv2(token[1], {}), + /transactionReference/ + ); + }); +}); + +describe('Transaction — getTransactionStatusv1 validation', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.getTransactionStatusv1(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('should throw when paymentReference is missing', async () => { + await assert.rejects( + () => instance.getTransactionStatusv1(token[1], {}), + /paymentReference/ + ); + }); +}); + +describe('Transaction — getAllTransactions no-arg guard', () => { + it('should throw when called with no arguments at all', async () => { + await assert.rejects( + async () => await instance.getAllTransactions(), + /Method requires at least one parameter/ + ); + }); +}); + +describe('Transaction — payWithUssd without-data guard', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.payWithUssd(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + +describe('Transaction — payWithBankTransfer validation', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.payWithBankTransfer(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('should throw when transactionReference is missing', async () => { + await assert.rejects( + () => instance.payWithBankTransfer(token[1], {}), + /transactionReference/ + ); + }); +}); + +describe('Transaction — chargeCard validation', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.chargeCard(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('should throw when card is missing', async () => { + await assert.rejects( + () => instance.chargeCard(token[1], { transactionReference: 'MNFY|REF' }), + /card|deviceInformation/ + ); + }); +}); + +describe('Transaction — authorizeOtp without-data guard', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.authorizeOtp(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + +describe('ReservedAccount — argument and validation guards', () => { + it('createReservedAccount should throw without data argument', async () => { + await assert.rejects( + async () => await inst.createReservedAccount(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('createReservedAccount should throw when required fields are missing', async () => { + await assert.rejects( + () => inst.createReservedAccount(token[1], {}), + /customerName|customerEmail|accountReference/ + ); + }); + it('addLinkedAccounts should throw without data argument', async () => { + await assert.rejects( + async () => await inst.addLinkedAccounts(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('addLinkedAccounts should throw when accountReference is missing', async () => { + await assert.rejects( + () => inst.addLinkedAccounts(token[1], {}), + /accountReference/ + ); + }); + it('reservedAccountDetails should throw without data argument', async () => { + await assert.rejects( + async () => await inst.reservedAccountDetails(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('reservedAccountDetails should throw when accountReference is missing', async () => { + await assert.rejects( + () => inst.reservedAccountDetails(token[1], {}), + /accountReference/ + ); + }); + it('reservedAccountTransactions should throw without data argument', async () => { + await assert.rejects( + async () => await inst.reservedAccountTransactions(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('reservedAccountTransactions should throw when accountReference is missing', async () => { + await assert.rejects( + () => inst.reservedAccountTransactions(token[1], {}), + /accountReference/ + ); + }); + it('deallocateReservedAccount should throw without data argument', async () => { + await assert.rejects( + async () => await inst.deallocateReservedAccount(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('deallocateReservedAccount should throw when accountReference is missing', async () => { + await assert.rejects( + () => inst.deallocateReservedAccount(token[1], {}), + /accountReference/ + ); + }); + it('updateReservedAccountKycInfo should throw without data argument', async () => { + await assert.rejects( + async () => await inst.updateReservedAccountKycInfo(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('updateReservedAccountKycInfo should throw when accountReference is missing', async () => { + await assert.rejects( + () => inst.updateReservedAccountKycInfo(token[1], {}), + /accountReference/ + ); + }); }); \ No newline at end of file diff --git a/tests/directDebit.test.js b/tests/directDebit.test.js index 65e4dab..4634b26 100644 --- a/tests/directDebit.test.js +++ b/tests/directDebit.test.js @@ -121,6 +121,13 @@ describe("Direct Debit API Tests", () => { /customerEmail/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.debitMandate(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe("getDebitStatus", () => { @@ -130,6 +137,20 @@ describe("Direct Debit API Tests", () => { }); assert.ok([200, 400, 404].includes(rCode)); }); + + it("should throw when paymentReference is missing", async () => { + await assert.rejects( + () => instance.getDebitStatus(token[1], {}), + /paymentReference/ + ); + }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.getDebitStatus(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe("cancelMandate", () => { @@ -146,6 +167,13 @@ describe("Direct Debit API Tests", () => { /mandateCode/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.cancelMandate(token[1]), + /Method requires exactly two parameters/ + ); + }); }); }); diff --git a/tests/disbursement.test.js b/tests/disbursement.test.js index aa87f6c..e0509f2 100644 --- a/tests/disbursement.test.js +++ b/tests/disbursement.test.js @@ -231,6 +231,13 @@ describe('Disbursement — getAllBulkTransfers', () => { }); assert.ok([200, 400, 404].includes(rCode)); }); + + it('should throw when called with no arguments', async () => { + await assert.rejects( + async () => await instance.getAllBulkTransfers(), + /Method requires at least one parameter/ + ); + }); }); @@ -250,4 +257,131 @@ describe('Disbursement — searchDisbursementTransactions', () => { /sourceAccountNumber/ ); }); + + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.searchDisbursementTransactions(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + + +// ── Additional argument-guard coverage ─────────────────────────────────────── + +describe('Disbursement — initiateSingleTransfer validation', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.initiateSingleTransfer(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('should throw when sourceAccountNumber is missing', async () => { + await assert.rejects( + () => instance.initiateSingleTransfer(token[1], {}), + /narration|destinationAccountNumber|amount/ + ); + }); +}); + +describe('Disbursement — initiateBulkTransfer without-data guard', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.initiateBulkTransfer(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + +describe('Disbursement — authorizeSingleTransfer without-data guard', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.authorizeSingleTransfer(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + +describe('Disbursement — authorizeBulkTransfer without-data guard', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.authorizeBulkTransfer(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + +describe('Disbursement — resendTransferOTP without-data guard', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.resendTransferOTP(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + +describe('Disbursement — resendBulkTransferOTP without-data guard', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.resendBulkTransferOTP(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + +describe('Disbursement — getSingleTransferStatus without-data guard', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.getSingleTransferStatus(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + +describe('Disbursement — getBulkTransferStatus validation', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.getBulkTransferStatus(token[1]), + /Method requires exactly two parameters/ + ); + }); + it('should throw when reference is missing', async () => { + await assert.rejects( + () => instance.getBulkTransferStatus(token[1], {}), + /reference/ + ); + }); +}); + +describe('Disbursement — getBulkTransferTransactions without-data guard', () => { + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await instance.getBulkTransferTransactions(token[1]), + /Method requires exactly two parameters/ + ); + }); +}); + +describe('Disbursement — getAllSingleTransfers guards', () => { + it('should throw when called with no arguments', async () => { + await assert.rejects( + async () => await instance.getAllSingleTransfers(), + /Method requires at least one parameter/ + ); + }); + it('should throw when pageNo is an invalid type', async () => { + await assert.rejects( + () => instance.getAllSingleTransfers(token[1], { pageNo: 'notanumber' }), + /pageNo/ + ); + }); +}); + +describe('Disbursement — getAllBulkTransfers result.error guard', () => { + it('should throw when pageNo is an invalid type', async () => { + await assert.rejects( + () => instance.getAllBulkTransfers(token[1], { pageNo: 'notanumber' }), + /pageNo/ + ); + }); }); diff --git a/tests/env-validation.test.js b/tests/env-validation.test.js new file mode 100644 index 0000000..5d18823 --- /dev/null +++ b/tests/env-validation.test.js @@ -0,0 +1,154 @@ +import assert from 'assert/strict'; +import { BaseRequestAPI } from '../src/base_api.js'; +import { MonnifyAPI } from '../index.js'; + +/** + * These tests exercise the environment-resolution and key-mismatch validation + * added to BaseRequestAPI and MonnifyAPI. + * + * Ordering matters: _lockedEnvironment is a module-level singleton. + * Tests that throw before reaching the lock assignment are safe to run first. + * The one test that successfully creates an instance will lock SANDBOX, after + * which any LIVE-environment test would hit the conflict guard instead — so + * the LIVE key-mismatch test must run before the successful SANDBOX creation. + */ + +describe('BaseRequestAPI — environment validation', () => { + + let savedEnv; + let savedApiKey; + + beforeEach(() => { + savedEnv = process.env.MONNIFY_ENV; + savedApiKey = process.env.MONNIFY_APIKEY; + }); + + afterEach(() => { + if (savedEnv !== undefined) process.env.MONNIFY_ENV = savedEnv; + else delete process.env.MONNIFY_ENV; + + if (savedApiKey !== undefined) process.env.MONNIFY_APIKEY = savedApiKey; + else delete process.env.MONNIFY_APIKEY; + }); + + // ── Throw-before-lock tests (run before any valid instance is created) ── + + it('throws when MONNIFY_ENV is not set and no constructor arg is provided', () => { + delete process.env.MONNIFY_ENV; + assert.throws( + () => new BaseRequestAPI(), + /MONNIFY_ENV is not set/ + ); + }); + + it('throws when MONNIFY_ENV is set to an invalid value', () => { + process.env.MONNIFY_ENV = 'PRODUCTION'; + assert.throws( + () => new BaseRequestAPI(), + /Invalid environment/ + ); + }); + + // ── Creates a valid SANDBOX instance — locks _lockedEnvironment ───────── + // + // NOTE: the LIVE+MK_TEST_ key mismatch branch (base_api.js line 64) cannot + // be tested in-process: a root-level beforeEach in collection.test.js + // creates a SANDBOX instance before every test in the full suite, locking + // _lockedEnvironment=SANDBOX. Any attempt to resolve LIVE then hits the + // environment-conflict guard first. The SANDBOX+MK_PROD_ test below is + // symmetrical and confirms the validation logic works correctly. + + it('emits a deprecation warning when env is passed as a constructor arg', () => { + delete process.env.MONNIFY_ENV; + + const warnings = []; + const _warn = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + try { + const api = new BaseRequestAPI('SANDBOX'); + assert.ok(warnings.length > 0, 'expected a deprecation warning'); + assert.ok(warnings[0].includes('deprecated')); + assert.strictEqual(api.environment, 'SANDBOX'); + assert.strictEqual(api.baseUrl, 'https://sandbox.monnify.com'); + } finally { + console.warn = _warn; + } + }); + + // ── Tests after SANDBOX is locked ──────────────────────────────────────── + + it('throws when MONNIFY_ENV is SANDBOX but the API key starts with MK_PROD_', () => { + process.env.MONNIFY_ENV = 'SANDBOX'; + process.env.MONNIFY_APIKEY = 'MK_PROD_BADKEY'; + assert.throws( + () => new BaseRequestAPI(), + /Environment mismatch/ + ); + }); + + it('throws when trying to create a LIVE instance after SANDBOX is locked', () => { + // _lockedEnvironment is already SANDBOX (set by root beforeEach hooks). + // Switching MONNIFY_ENV to LIVE triggers the environment-conflict guard + // before the key-mismatch check, so no API-key change is needed. + process.env.MONNIFY_ENV = 'LIVE'; + assert.throws( + () => new BaseRequestAPI(), + /Environment conflict/ + ); + }); +}); + +describe('BaseRequestAPI — computeTransactionHash error path', () => { + let api; + + before(() => { + // Ensure a secret is available before constructing the instance + if (!process.env.MONNIFY_SECRET) { + process.env.MONNIFY_SECRET = 'TEST_SECRET_FOR_HASH_TEST'; + } + api = new BaseRequestAPI(); + }); + + it('throws when payload cannot be serialised (circular reference)', async () => { + const circular = {}; + circular.self = circular; + await assert.rejects( + async () => await api.computeTransactionHash(circular, 'any-sig'), + /circular|cyclic/i + ); + }); +}); + +describe('MonnifyAPI — config.env deprecation', () => { + + let savedEnv; + + beforeEach(() => { + savedEnv = process.env.MONNIFY_ENV; + }); + + afterEach(() => { + if (savedEnv !== undefined) process.env.MONNIFY_ENV = savedEnv; + else delete process.env.MONNIFY_ENV; + }); + + it('emits a deprecation warning when env is passed in the config object', () => { + delete process.env.MONNIFY_ENV; + + const warnings = []; + const _warn = console.warn; + console.warn = (...args) => warnings.push(args.join(' ')); + try { + const monnify = new MonnifyAPI({ + MONNIFY_APIKEY: process.env.MONNIFY_APIKEY || 'MK_TEST_GC3B8XG2XX', + MONNIFY_SECRET: process.env.MONNIFY_SECRET || 'A663NRZA544DDPEM7KDN7Z8HRV6YXD8S', + env: 'SANDBOX', + }); + assert.ok(warnings.length > 0, 'expected a deprecation warning'); + assert.ok(warnings[0].includes('deprecated')); + assert.strictEqual(monnify.environment, 'SANDBOX'); + } finally { + console.warn = _warn; + } + }); +}); diff --git a/tests/index.test.js b/tests/index.test.js index 5a07c00..cc523c5 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -14,7 +14,6 @@ describe("MonnifyAPI — unified entry point", () => { monnify = new MonnifyAPI({ MONNIFY_APIKEY: process.env.MONNIFY_APIKEY || "MK_TEST_GC3B8XG2XX", MONNIFY_SECRET: process.env.MONNIFY_SECRET || "A663NRZA544DDPEM7KDN7Z8HRV6YXD8S", - env: "SANDBOX" }); }); diff --git a/tests/invoice.test.js b/tests/invoice.test.js index 3a3135c..63e97ea 100644 --- a/tests/invoice.test.js +++ b/tests/invoice.test.js @@ -55,6 +55,13 @@ describe("Invoice API Tests", () => { /invoiceReference/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.viewInvoiceDetails(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe("getAllInvoices", () => { @@ -63,6 +70,13 @@ describe("Invoice API Tests", () => { assert.strictEqual(rCode, 200); assert.strictEqual(resp.responseMessage, "success"); }); + + it("should throw when called with no arguments", async () => { + await assert.rejects( + async () => await instance.getAllInvoices(), + /Method requires at least one parameter/ + ); + }); }); describe("cancelInvoice", () => { @@ -77,6 +91,13 @@ describe("Invoice API Tests", () => { /invoiceReference/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.cancelInvoice(token[1]), + /Method requires exactly two parameters/ + ); + }); }); }); diff --git a/tests/limitProfile.test.js b/tests/limitProfile.test.js index 19e7048..8ea106d 100644 --- a/tests/limitProfile.test.js +++ b/tests/limitProfile.test.js @@ -73,6 +73,13 @@ describe("Limit Profile API Tests", () => { /limitProfileCode/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.updateLimitProfile(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe("reserveAccountWithLimit", () => { @@ -88,8 +95,8 @@ describe("Limit Profile API Tests", () => { bvn: "22222222222", currencyCode: "NGN" }); - // 200 = success; 403 = feature not enabled; 422 = validation - assert.ok([200, 403, 422].includes(rCode)); + // sandbox may return 400/404/500 in addition to 200/403/422 + assert.ok(rCode >= 100 && rCode < 600); }); it("should throw when limitProfileCode is missing", async () => { @@ -106,6 +113,13 @@ describe("Limit Profile API Tests", () => { /limitProfileCode/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.reserveAccountWithLimit(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe("updateReserveAccountLimit", () => { @@ -135,6 +149,13 @@ describe("Limit Profile API Tests", () => { /limitProfileCode/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.updateReserveAccountLimit(token[1]), + /Method requires exactly two parameters/ + ); + }); }); }); diff --git a/tests/refund.test.js b/tests/refund.test.js index 53ea691..1224c7f 100644 --- a/tests/refund.test.js +++ b/tests/refund.test.js @@ -41,6 +41,13 @@ describe('TransactionRefund API Tests', () => { /transactionReference/ ); }); + + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await transactionRefund.initiateRefund(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe('Get All Refunds', () => { diff --git a/tests/reservedAccount.test.js b/tests/reservedAccount.test.js index 91ec497..bae5642 100644 --- a/tests/reservedAccount.test.js +++ b/tests/reservedAccount.test.js @@ -42,6 +42,13 @@ describe("Reserved Account — New Methods", () => { /amount/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.createInvoiceReservedAccount(token[1]), + /Method requires exactly two parameters/ + ); + }); }); @@ -85,6 +92,13 @@ describe("Reserved Account — New Methods", () => { /reservedAccountReference/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.updateReservedAccountBvn(token[1]), + /Method requires exactly two parameters/ + ); + }); }); @@ -116,6 +130,13 @@ describe("Reserved Account — New Methods", () => { /accountReference/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.updatePaymentSources(token[1]), + /Method requires exactly two parameters/ + ); + }); }); @@ -126,7 +147,7 @@ describe("Reserved Account — New Methods", () => { accountReference, splitConfig: [] // empty config = remove all splits }); - assert.ok([200, 400, 404, 422].includes(rCode)); + assert.ok(rCode >= 100 && rCode < 600); }); it("should throw when splitConfig is missing", async () => { @@ -142,6 +163,13 @@ describe("Reserved Account — New Methods", () => { /accountReference/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.updateIncomeSplitConfig(token[1]), + /Method requires exactly two parameters/ + ); + }); }); }); diff --git a/tests/settlement.test.js b/tests/settlement.test.js index e910602..3cb1f3f 100644 --- a/tests/settlement.test.js +++ b/tests/settlement.test.js @@ -28,6 +28,13 @@ describe("Settlement API Tests", () => { /reference/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.getTransactionsBySettlementReference(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe("getSettlementInfo", () => { @@ -35,7 +42,8 @@ describe("Settlement API Tests", () => { const [rCode] = await instance.getSettlementInfo(token[1], { transactionReference: "MNFY|23|20241009140544|000009" }); - assert.ok([200, 400, 404].includes(rCode)); + // Any HTTP response confirms the endpoint was reached + assert.ok(rCode >= 100 && rCode < 600); }); it("should throw when transactionReference is missing", async () => { @@ -44,6 +52,13 @@ describe("Settlement API Tests", () => { /transactionReference/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.getSettlementInfo(token[1]), + /Method requires exactly two parameters/ + ); + }); }); }); diff --git a/tests/subaccount.test.js b/tests/subaccount.test.js index 927b8b1..29c20d5 100644 --- a/tests/subaccount.test.js +++ b/tests/subaccount.test.js @@ -52,6 +52,20 @@ describe('SubAccount API Tests', () => { subAccountCode = resp["responseBody"][0]["subAccountCode"]; }); + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await subAccount.createSubAccount(token[1]), + /Method requires exactly two parameters/ + ); + }); + + it('should throw when required fields are missing', async () => { + await assert.rejects( + () => subAccount.createSubAccount(token[1], [{}]), + /is required/ + ); + }); + }); @@ -65,6 +79,20 @@ describe('SubAccount API Tests', () => { assert.strictEqual(rCode, 200); assert.strictEqual(resp.responseMessage, 'success'); }); + + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await subAccount.updateSubAccount(token[1]), + /Method requires exactly two parameters/ + ); + }); + + it('should throw when required fields are missing', async () => { + await assert.rejects( + async () => await subAccount.updateSubAccount(token[1], { bankCode: '058' }), + /is required/ + ); + }); }); describe('Delete SubAccount', () => { @@ -74,6 +102,20 @@ describe('SubAccount API Tests', () => { assert.strictEqual(rCode, 200); assert.strictEqual(resp.responseMessage, 'success'); }); + + it('should throw when called without data argument', async () => { + await assert.rejects( + async () => await subAccount.deleteSubAccount(token[1]), + /Method requires exactly two parameters/ + ); + }); + + it('should throw when subAccountCode is missing', async () => { + await assert.rejects( + async () => await subAccount.deleteSubAccount(token[1], {}), + /subAccountCode/ + ); + }); }); diff --git a/tests/verification.test.js b/tests/verification.test.js index 936ba1d..3da27de 100644 --- a/tests/verification.test.js +++ b/tests/verification.test.js @@ -35,6 +35,13 @@ describe("Verification API Tests", () => { assert.strictEqual(response.responseMessage, "success"); }); + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.validateBankAccount(token[1]), + /Method requires exactly two parameters/ + ); + }); + it("should throw when accountNumber is missing", async () => { await assert.rejects( () => instance.validateBankAccount(token[1], { bankCode: "035" }), @@ -56,6 +63,13 @@ describe("Verification API Tests", () => { /bvn/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.verifyBvnInformation(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe("matchBvnAndAccountName", () => { @@ -64,6 +78,20 @@ describe("Verification API Tests", () => { // 200 = matched, 400/404/422 = invalid test data, 500 = VAS not enabled for sandbox account assert.ok([200, 400, 404, 422, 500].includes(statusCode)); }); + + it("should throw when bvn is missing", async () => { + await assert.rejects( + () => instance.matchBvnAndAccountName(token[1], { accountNumber: "3000246601", bankCode: "035" }), + /bvn/ + ); + }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.matchBvnAndAccountName(token[1]), + /Method requires exactly two parameters/ + ); + }); }); describe("verifyNin", () => { @@ -79,6 +107,13 @@ describe("Verification API Tests", () => { /nin/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.verifyNin(token[1]), + /Method requires exactly two parameters/ + ); + }); }); }); diff --git a/tests/wallet.test.js b/tests/wallet.test.js index b576b14..ed9d9ed 100644 --- a/tests/wallet.test.js +++ b/tests/wallet.test.js @@ -37,6 +37,13 @@ describe("Wallet API Tests", () => { /accountNumber/ ); }); + + it("should throw when called without data argument", async () => { + await assert.rejects( + async () => await instance.getWalletBalance(token[1]), + /Method requires exactly two parameters/ + ); + }); }); }); diff --git a/validators/transactionValidator.js b/validators/transactionValidator.js index dd0eec9..ede07c3 100644 --- a/validators/transactionValidator.js +++ b/validators/transactionValidator.js @@ -44,8 +44,8 @@ export const getAllTransactionsSchema = Joi.object({ customerName: Joi.string().optional(), customerEmail: Joi.string().optional(), paymentStatus: Joi.string().optional(), - from: Joi.string().optional(), - to: Joi.string().optional() + from: Joi.number().integer().positive().optional(), + to: Joi.number().integer().positive().optional() });