diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml
index b2bebe3..6afd9a9 100644
--- a/.github/workflows/node.js.yml
+++ b/.github/workflows/node.js.yml
@@ -27,9 +27,9 @@ jobs:
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js ${{ matrix.node-version }}
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: "npm"
@@ -40,9 +40,9 @@ jobs:
packaging-smoke:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.x
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: 24.x
cache: "npm"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 1b9210d..3965f64 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -17,12 +17,13 @@ jobs:
publish:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.x
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: 24.x
registry-url: "https://registry.npmjs.org/"
+ package-manager-cache: false
- run: npm ci
- run: npm run build --if-present
- name: Verify package contents
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 9ddd425..04de69c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -50,10 +50,10 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Setup Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: 24.x
@@ -108,7 +108,7 @@ jobs:
sha256sum ${{ matrix.binary-name }} > ${{ matrix.binary-name }}.sha256
- name: Upload artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: ${{ matrix.binary-name }}
path: |
@@ -124,10 +124,10 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Download all artifacts
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
path: artifacts
@@ -246,7 +246,7 @@ jobs:
VERSION: ${{ steps.tag_version.outputs.version }}
- name: Create GitHub Release
- uses: softprops/action-gh-release@v1
+ uses: softprops/action-gh-release@v3
with:
files: release-assets/*
name: Release v${{ steps.tag_version.outputs.version }}
diff --git a/.github/workflows/snap-release.yml b/.github/workflows/snap-release.yml
index 3e43a7f..5268885 100644
--- a/.github/workflows/snap-release.yml
+++ b/.github/workflows/snap-release.yml
@@ -20,10 +20,10 @@ jobs:
contents: write
steps:
- name: Checkout code
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Setup Node.js 24.x
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: 24.x
cache: npm
@@ -64,7 +64,7 @@ jobs:
echo "Snap file: $SNAP_FILE"
- name: Upload snap artifact to GitHub Release
- uses: softprops/action-gh-release@v1
+ uses: softprops/action-gh-release@v3
with:
files: ${{ steps.snap_file.outputs.file }}
env:
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5bff8d9..13c84bb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -215,7 +215,7 @@ User-facing README download examples should use the same version as `package.jso
```sh
IPB_NO_UPDATE_CHECK=1 npm run tapes
-# runs scripts/tapes.sh — requires VHS and a built CLI
+# builds, npm links local ipb, then runs scripts/tapes.sh — requires VHS
```
Regenerate assets under `assets/` when UX of recorded commands changes meaningfully.
diff --git a/README.md b/README.md
index c316909..e29bfce 100644
--- a/README.md
+++ b/README.md
@@ -137,12 +137,12 @@ macOS:
```sh
# Apple Silicon
-curl -L https://github.com/devinpearson/ipb/releases/download/v0.8.4/ipb-macos-arm64 -o ipb
+curl -L https://github.com/devinpearson/ipb/releases/download/v0.9.0/ipb-macos-arm64 -o ipb
chmod +x ipb
sudo mv ipb /usr/local/bin/
# Intel
-curl -L https://github.com/devinpearson/ipb/releases/download/v0.8.4/ipb-macos-x64 -o ipb
+curl -L https://github.com/devinpearson/ipb/releases/download/v0.9.0/ipb-macos-x64 -o ipb
chmod +x ipb
sudo mv ipb /usr/local/bin/
```
@@ -150,15 +150,15 @@ sudo mv ipb /usr/local/bin/
Linux (.deb):
```sh
-wget https://github.com/devinpearson/ipb/releases/download/v0.8.4/ipb_0.8.4_amd64.deb
-sudo dpkg -i ipb_0.8.4_amd64.deb
+wget https://github.com/devinpearson/ipb/releases/download/v0.9.0/ipb_0.9.0_amd64.deb
+sudo dpkg -i ipb_0.9.0_amd64.deb
sudo apt-get install -f
```
Linux binary:
```sh
-curl -L https://github.com/devinpearson/ipb/releases/download/v0.8.4/ipb-linux-x64 -o ipb
+curl -L https://github.com/devinpearson/ipb/releases/download/v0.9.0/ipb-linux-x64 -o ipb
# or: ipb-linux-arm64
chmod +x ipb
sudo mv ipb /usr/local/bin/
diff --git a/assets/accounts.gif b/assets/accounts.gif
index a16b2c8..db958f5 100644
Binary files a/assets/accounts.gif and b/assets/accounts.gif differ
diff --git a/assets/balances.gif b/assets/balances.gif
index d1b87de..54560c2 100644
Binary files a/assets/balances.gif and b/assets/balances.gif differ
diff --git a/assets/beneficiaries.gif b/assets/beneficiaries.gif
index c7c5b87..bcc391a 100644
Binary files a/assets/beneficiaries.gif and b/assets/beneficiaries.gif differ
diff --git a/assets/cards.gif b/assets/cards.gif
index 69d0d94..0bdb2b9 100644
Binary files a/assets/cards.gif and b/assets/cards.gif differ
diff --git a/assets/deploy.gif b/assets/deploy.gif
index 0b4473b..0e510d9 100644
Binary files a/assets/deploy.gif and b/assets/deploy.gif differ
diff --git a/assets/env.gif b/assets/env.gif
index 05bb076..f55ba81 100644
Binary files a/assets/env.gif and b/assets/env.gif differ
diff --git a/assets/fetch.gif b/assets/fetch.gif
index c1afc14..cd8486b 100644
Binary files a/assets/fetch.gif and b/assets/fetch.gif differ
diff --git a/assets/logs.gif b/assets/logs.gif
index 641dfea..9a12522 100644
Binary files a/assets/logs.gif and b/assets/logs.gif differ
diff --git a/assets/new.gif b/assets/new.gif
index d1bb318..28f0c15 100644
Binary files a/assets/new.gif and b/assets/new.gif differ
diff --git a/assets/pay.gif b/assets/pay.gif
index e27da18..c91ba4e 100644
Binary files a/assets/pay.gif and b/assets/pay.gif differ
diff --git a/assets/publish.gif b/assets/publish.gif
index 0d624de..800eb93 100644
Binary files a/assets/publish.gif and b/assets/publish.gif differ
diff --git a/assets/published.gif b/assets/published.gif
index 13e09a2..516cc4e 100644
Binary files a/assets/published.gif and b/assets/published.gif differ
diff --git a/assets/run.gif b/assets/run.gif
index e48c290..00849ed 100644
Binary files a/assets/run.gif and b/assets/run.gif differ
diff --git a/assets/simulate.gif b/assets/simulate.gif
index bf9c1e4..e138862 100644
Binary files a/assets/simulate.gif and b/assets/simulate.gif differ
diff --git a/assets/toggle.gif b/assets/toggle.gif
index cf13813..a55f625 100644
Binary files a/assets/toggle.gif and b/assets/toggle.gif differ
diff --git a/assets/transactions.gif b/assets/transactions.gif
index 79dfe2d..5baad35 100644
Binary files a/assets/transactions.gif and b/assets/transactions.gif differ
diff --git a/assets/transfer.gif b/assets/transfer.gif
index c50c205..04aded0 100644
Binary files a/assets/transfer.gif and b/assets/transfer.gif differ
diff --git a/assets/upload-env.gif b/assets/upload-env.gif
index ad17c52..38273c5 100644
Binary files a/assets/upload-env.gif and b/assets/upload-env.gif differ
diff --git a/assets/upload.gif b/assets/upload.gif
index 4247b94..7d97b7d 100644
Binary files a/assets/upload.gif and b/assets/upload.gif differ
diff --git a/package-lock.json b/package-lock.json
index ac3e3b2..c1997aa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "investec-ipb",
- "version": "0.8.4",
+ "version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "investec-ipb",
- "version": "0.8.4",
+ "version": "0.9.0",
"license": "MIT",
"dependencies": {
"@inquirer/prompts": "^7.9.0",
diff --git a/package.json b/package.json
index 187499c..3ea47bb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "investec-ipb",
- "version": "0.8.4",
+ "version": "0.9.0",
"main": "bin/index.js",
"bin": {
"ipb": "./bin/index.js"
@@ -9,7 +9,7 @@
"scripts": {
"build": "npm run clean && tsc && npm run copy-files",
"clean": "rimraf ./bin",
- "copy-files": "cp -r ./templates/ ./bin/templates/ && cp -r ./assets/ ./bin/assets/ && cp instructions.txt ./bin/instructions.txt",
+ "copy-files": "cp -r ./templates/ ./bin/templates/ && cp -r ./assets/ ./bin/assets/ && cp instructions.txt ./bin/instructions.txt && chmod +x ./bin/index.js",
"test": "vitest",
"test:run": "vitest --run",
"lint": "biome check .",
@@ -23,7 +23,7 @@
"ci": "npm run build && npm run type-check && npm run lint && npm run lint:md && npm run format:check && npm run test:run && npm audit",
"verify:ci": "npm run build && npm run test:run && npm run bundle",
"dev": "vitest",
- "tapes": "./scripts/tapes.sh",
+ "tapes": "npm run build && chmod +x ./bin/index.js && npm link && ./scripts/tapes.sh",
"docs": "npm run build && node bin/index.js docs",
"docs:source": "node --loader tsx src/cmds/docs.ts",
"prepublishOnly": "npm run build && npm run docs",
diff --git a/scripts/tapes.sh b/scripts/tapes.sh
index 9eaf7b1..e308eb3 100755
--- a/scripts/tapes.sh
+++ b/scripts/tapes.sh
@@ -1,21 +1,50 @@
#!/usr/bin/env bash
+set -euo pipefail
+
+# Ctrl+C / SIGTERM should stop the whole suite, not just the current vhs.
+trap 'printf "\nInterrupted — stopping tape generation.\n" >&2; exit 130' INT TERM
+
export DEBUG=true
-vhs tapes/cards.tape
-vhs tapes/deploy.tape
-vhs tapes/env.tape
-vhs tapes/fetch.tape
-vhs tapes/logs.tape
-vhs tapes/new.tape
-vhs tapes/publish.tape
-vhs tapes/published.tape
-vhs tapes/run.tape
-vhs tapes/simulate.tape
-vhs tapes/toggle.tape
-vhs tapes/upload-env.tape
-vhs tapes/upload.tape
-vhs tapes/accounts.tape
-vhs tapes/beneficiaries.tape
-vhs tapes/balances.tape
-vhs tapes/transactions.tape
-vhs tapes/pay.tape
-vhs tapes/transfer.tape
+export IPB_NO_UPDATE_CHECK="${IPB_NO_UPDATE_CHECK:-1}"
+
+# npm link points the global `ipb` at ./bin/index.js; tsc emits without +x.
+if [[ ! -x ./bin/index.js ]]; then
+ chmod +x ./bin/index.js
+fi
+if ! command -v ipb >/dev/null 2>&1; then
+ printf 'ipb is not on PATH. Run npm link after building.\n' >&2
+ exit 1
+fi
+if ! ipb --version >/dev/null 2>&1; then
+ printf 'Linked ipb is not executable (permission denied). Fixing ./bin/index.js and retrying.\n' >&2
+ chmod +x ./bin/index.js
+ ipb --version >/dev/null
+fi
+
+tapes=(
+ cards
+ deploy
+ env
+ fetch
+ logs
+ new
+ publish
+ published
+ run
+ simulate
+ toggle
+ upload-env
+ upload
+ accounts
+ beneficiaries
+ balances
+ transactions
+ pay
+ transfer
+)
+
+for name in "${tapes[@]}"; do
+ printf 'Recording tapes/%s.tape...\n' "$name"
+ # Run in foreground process group so SIGINT reaches vhs.
+ vhs "tapes/${name}.tape"
+done
diff --git a/src/cmds/fetch.ts b/src/cmds/fetch.ts
index c8394b0..c5263e5 100644
--- a/src/cmds/fetch.ts
+++ b/src/cmds/fetch.ts
@@ -20,7 +20,7 @@ interface Options extends CommonOptions {
/**
* Fetches saved code from a card and saves it to a file.
* @param options - CLI options including card key, filename, and API credentials
- * @throws {CliError} When card key is missing, API doesn't support fetching, or file operations fail
+ * @throws {CliError} When card key is missing, API response is unexpected, or file operations fail
*/
export async function fetchCommand(options: Options) {
const cardKey = normalizeCardKey(options.cardKey, credentials.cardKey);
@@ -36,17 +36,7 @@ export async function fetchCommand(options: Options) {
let normalizedFilename = '';
await withSpinner(spinner, spinnerEnabled, async () => {
const api = await initializeApi(credentials, options);
-
- // The api object may not have a getCode method; use getSavedCode if available, or handle gracefully
- // biome-ignore lint/suspicious/noExplicitAny: API interface may not include all methods
- if (typeof (api as any).getSavedCode !== 'function') {
- throw new CliError(
- ERROR_CODES.UNSUPPORTED_OPERATION,
- 'API client does not support fetching saved code (getSavedCode missing)'
- );
- }
- // biome-ignore lint/suspicious/noExplicitAny: API interface may not include all methods
- const result = await (api as any).getSavedCode(cardKey);
+ const result = await api.getCode(cardKey);
if (
!result ||
@@ -60,15 +50,7 @@ export async function fetchCommand(options: Options) {
);
}
- const fetchedCode = result.data.result.code;
- if (typeof fetchedCode !== 'string') {
- throw new CliError(
- ERROR_CODES.INVESTEC_API_ERROR,
- 'Failed to fetch code: Unexpected API response'
- );
- }
-
- code = fetchedCode;
+ code = result.data.result.code;
normalizedFilename = await validateFilePathForWrite(options.filename, ['.js']);
});
diff --git a/src/cmds/pay.ts b/src/cmds/pay.ts
index 3b4592d..d52a176 100644
--- a/src/cmds/pay.ts
+++ b/src/cmds/pay.ts
@@ -27,7 +27,7 @@ import type { CommonOptions } from './types.js';
export async function payCommand(
accountId: string,
beneficiaryId: string,
- amount: number,
+ amount: number | string,
reference: string,
options: CommonOptions
) {
@@ -45,11 +45,11 @@ export async function payCommand(
throw new CliError(ERROR_CODES.INVALID_INPUT, 'Beneficiary ID is required');
}
+ // Commander passes args as strings; coerce before validation/formatting.
if (!amount) {
- const amt = await input({ message: 'Enter amount (in rands):' });
- amount = parseFloat(amt);
+ amount = await input({ message: 'Enter amount (in rands):' });
}
- validateAmount(amount);
+ amount = validateAmount(amount);
if (!reference) {
reference = await input({ message: 'Enter reference for the payment:' });
diff --git a/src/cmds/transfer.ts b/src/cmds/transfer.ts
index 1c67be4..0cf4625 100644
--- a/src/cmds/transfer.ts
+++ b/src/cmds/transfer.ts
@@ -25,7 +25,7 @@ import type { CommonOptions } from './types.js';
export async function transferCommand(
accountId: string,
beneficiaryAccountId: string,
- amount: number,
+ amount: number | string,
reference: string,
options: CommonOptions
) {
@@ -42,11 +42,11 @@ export async function transferCommand(
}
validateAccountId(beneficiaryAccountId);
+ // Commander passes args as strings; coerce before validation/formatting.
if (!amount) {
- const amt = await input({ message: 'Enter amount (in rands):' });
- amount = parseFloat(amt);
+ amount = await input({ message: 'Enter amount (in rands):' });
}
- validateAmount(amount);
+ amount = validateAmount(amount);
if (!reference) {
reference = await input({ message: 'Enter reference for the transfer:' });
diff --git a/src/utils/input-validation.ts b/src/utils/input-validation.ts
index 14ee559..23e5fb2 100644
--- a/src/utils/input-validation.ts
+++ b/src/utils/input-validation.ts
@@ -1,25 +1,40 @@
import { CliError, ERROR_CODES } from '../errors.js';
-export function validateAmount(amount: number, maxDecimals = 2): void {
- if (Number.isNaN(amount)) {
+/**
+ * Validates a monetary amount and returns it as a number.
+ * Accepts CLI string args (Commander always passes strings) or numbers.
+ * @param amount - Amount in rands
+ * @param maxDecimals - Maximum allowed fractional digits
+ * @returns Parsed finite positive amount
+ */
+export function validateAmount(amount: number | string, maxDecimals = 2): number {
+ if (typeof amount === 'string' && amount.trim() === '') {
throw new CliError(ERROR_CODES.INVALID_INPUT, 'Amount must be a valid number');
}
- if (amount <= 0) {
+ const value = typeof amount === 'string' ? Number(amount.trim()) : Number(amount);
+
+ if (Number.isNaN(value)) {
+ throw new CliError(ERROR_CODES.INVALID_INPUT, 'Amount must be a valid number');
+ }
+
+ if (value <= 0) {
throw new CliError(ERROR_CODES.INVALID_INPUT, 'Amount must be positive');
}
- if (!Number.isFinite(amount)) {
+ if (!Number.isFinite(value)) {
throw new CliError(ERROR_CODES.INVALID_INPUT, 'Amount must be a finite number');
}
- const decimalPlaces = (amount.toString().split('.')[1] || '').length;
+ const decimalPlaces = (value.toString().split('.')[1] || '').length;
if (decimalPlaces > maxDecimals) {
throw new CliError(
ERROR_CODES.INVALID_INPUT,
`Amount can have at most ${maxDecimals} decimal place${maxDecimals === 1 ? '' : 's'}. Found ${decimalPlaces} decimal place${decimalPlaces === 1 ? '' : 's'}.`
);
}
+
+ return value;
}
export function validateAccountId(accountId: string): void {
diff --git a/tapes/accounts.tape b/tapes/accounts.tape
index 611152f..17384a9 100644
--- a/tapes/accounts.tape
+++ b/tapes/accounts.tape
@@ -1,6 +1,6 @@
Output assets/accounts.gif
Source tapes/config.tape
-Type "ipb accounts" Sleep 500ms Enter
+Type "ipb accounts" Sleep 1000ms Enter
Sleep 2s
\ No newline at end of file
diff --git a/tapes/balances.tape b/tapes/balances.tape
index e64c31c..f810f91 100644
--- a/tapes/balances.tape
+++ b/tapes/balances.tape
@@ -1,6 +1,6 @@
Output assets/balances.gif
Source tapes/config.tape
-Type "ipb balances mock-account-id" Sleep 500ms Enter
+Type "ipb balances mock-account-id" Sleep 1000ms Enter
Sleep 2s
diff --git a/tapes/beneficiaries.tape b/tapes/beneficiaries.tape
index 88a790a..1e8833e 100644
--- a/tapes/beneficiaries.tape
+++ b/tapes/beneficiaries.tape
@@ -1,6 +1,6 @@
Output assets/beneficiaries.gif
Source tapes/config.tape
-Type "ipb beneficiaries" Sleep 500ms Enter
+Type "ipb beneficiaries" Sleep 1000ms Enter
Sleep 2s
diff --git a/tapes/cards.tape b/tapes/cards.tape
index 454d315..bb2093a 100644
--- a/tapes/cards.tape
+++ b/tapes/cards.tape
@@ -1,6 +1,6 @@
Output assets/cards.gif
Source tapes/config.tape
-Type "ipb cards" Sleep 500ms Enter
+Type "ipb cards" Sleep 1000ms Enter
Sleep 2s
diff --git a/tapes/config.tape b/tapes/config.tape
index c0e437e..54f42fb 100644
--- a/tapes/config.tape
+++ b/tapes/config.tape
@@ -2,4 +2,4 @@ Set Shell "bash"
Set FontSize 24
Set Width 1400
Set Height 600
-Set TypingSpeed 500ms
\ No newline at end of file
+Set TypingSpeed 250ms
\ No newline at end of file
diff --git a/tapes/deploy.tape b/tapes/deploy.tape
index cfd95c3..3ed7414 100644
--- a/tapes/deploy.tape
+++ b/tapes/deploy.tape
@@ -1,6 +1,7 @@
Output assets/deploy.gif
Source tapes/config.tape
-Type "ipb deploy -f main.js -e prod -c 1111111" Sleep 500ms Enter
+Type "ipb deploy -f main.js -e prod -c 1111111" Sleep 1000ms Enter
+Type "y" Sleep 500ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/env.tape b/tapes/env.tape
index b291de4..846b4ad 100644
--- a/tapes/env.tape
+++ b/tapes/env.tape
@@ -1,6 +1,6 @@
Output assets/env.gif
Source tapes/config.tape
-Type "ipb env -f env.json -c 1111111" Sleep 500ms Enter
+Type "ipb env -f env.json -c 1111111" Sleep 1000ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/fetch.tape b/tapes/fetch.tape
index f07d07d..5b21f39 100644
--- a/tapes/fetch.tape
+++ b/tapes/fetch.tape
@@ -1,6 +1,6 @@
Output assets/fetch.gif
Source tapes/config.tape
-Type "ipb fetch -f main.js -c 1111111" Sleep 500ms Enter
+Type "ipb fetch -f main.js -c 1111111" Sleep 1000ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/logs.tape b/tapes/logs.tape
index 2597eb2..b67b4bc 100644
--- a/tapes/logs.tape
+++ b/tapes/logs.tape
@@ -1,6 +1,6 @@
Output assets/logs.gif
Source tapes/config.tape
-Type "ipb logs -f executions.json -c 1111111" Sleep 500ms Enter
+Type "ipb logs -f executions.json -c 1111111" Sleep 1000ms Enter
Sleep 2s
\ No newline at end of file
diff --git a/tapes/new.tape b/tapes/new.tape
index 801befa..bcb17c1 100644
--- a/tapes/new.tape
+++ b/tapes/new.tape
@@ -1,14 +1,14 @@
Output assets/new.gif
Source tapes/config.tape
-Type "ipb new myproject" Sleep 500ms Enter
+Type "ipb new myproject" Sleep 1000ms Enter
Sleep 2s
-Type "cd myproject" Sleep 500ms Enter
+Type "cd myproject" Sleep 1000ms Enter
Sleep 2s
-Type "ls -a" Sleep 500ms Enter
+Type "ls -a" Sleep 1000ms Enter
Sleep 5s
diff --git a/tapes/pay.tape b/tapes/pay.tape
index 850f7bd..6702dc8 100644
--- a/tapes/pay.tape
+++ b/tapes/pay.tape
@@ -1,6 +1,8 @@
Output assets/pay.gif
Source tapes/config.tape
-Type "ipb pay mock-account-id b1 100.00 test-payment" Sleep 500ms Enter
+Type "ipb pay mock-account-id b1 100.00 test-payment" Sleep 1000ms Enter
+
+Type "y" Sleep 500ms Enter
Sleep 2s
diff --git a/tapes/publish.tape b/tapes/publish.tape
index 9da8fc5..a27e8a0 100644
--- a/tapes/publish.tape
+++ b/tapes/publish.tape
@@ -2,5 +2,6 @@ Output assets/publish.gif
Source tapes/config.tape
Type "ipb publish -f main.js --code-id 2b388c8a-daaf-44f1-bcf9-ca3482d641f3 -c 1111111" Sleep 500ms Enter
+Type "y" Sleep 500ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/published.tape b/tapes/published.tape
index a09fac6..d1a5ad4 100644
--- a/tapes/published.tape
+++ b/tapes/published.tape
@@ -1,6 +1,6 @@
Output assets/published.gif
Source tapes/config.tape
-Type "ipb published -f published.js -c 1111111" Sleep 500ms Enter
+Type "ipb published -f published.js -c 1111111" Sleep 1000ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/run.tape b/tapes/run.tape
index bada56a..b9dc652 100644
--- a/tapes/run.tape
+++ b/tapes/run.tape
@@ -1,6 +1,6 @@
Output assets/run.gif
Source tapes/config.tape
-Type "ipb run -f main.js -e prod --amount 10000 --currency zar --mcc 5933 --merchant 'Second chance' --city 'Cape Town' --country ZA" Sleep 500ms Enter
+Type "ipb run -f main.js -e prod --amount 10000 --currency zar --mcc 5933 --merchant 'Second chance' --city 'Cape Town' --country ZA" Sleep 1000ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/set.tape b/tapes/set.tape
index 8dff591..d7d96a5 100644
--- a/tapes/set.tape
+++ b/tapes/set.tape
@@ -1,6 +1,6 @@
Output assets/set.gif
Source tapes/config.tape
-Type "ipb config --client-id 1234 --client-secret 678776 --api-key 3738373 --card-key 78768" Sleep 500ms Enter
+Type "ipb config --client-id 1234 --client-secret 678776 --api-key 3738373 --card-key 78768" Sleep 1000ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/simulate.tape b/tapes/simulate.tape
index 1e4daa2..4bcb307 100644
--- a/tapes/simulate.tape
+++ b/tapes/simulate.tape
@@ -1,6 +1,6 @@
Output assets/simulate.gif
Source tapes/config.tape
-Type "ipb simulate -f main.js -c 1111111 --amount 10000 --currency zar --mcc 5933 --merchant 'Second chance' --city 'Cape Town' --country ZA" Sleep 500ms Enter
+Type "ipb simulate -f main.js -c 1111111 --amount 10000 --currency zar --mcc 5933 --merchant 'Second chance' --city 'Cape Town' --country ZA" Sleep 1000ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/toggle.tape b/tapes/toggle.tape
index 6e40e98..c185af0 100644
--- a/tapes/toggle.tape
+++ b/tapes/toggle.tape
@@ -1,10 +1,12 @@
Output assets/toggle.gif
Source tapes/config.tape
-Type "ipb enable -c 1111111" Sleep 500ms Enter
+Type "ipb enable -c 1111111" Sleep 1000ms Enter
Sleep 5s
-Type "ipb disable -c 1111111" Sleep 500ms Enter
+Type "ipb disable -c 1111111" Sleep 1000ms Enter
+
+Type "y" Sleep 500ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/transactions.tape b/tapes/transactions.tape
index 861ecde..88c7ace 100644
--- a/tapes/transactions.tape
+++ b/tapes/transactions.tape
@@ -1,6 +1,6 @@
Output assets/transactions.gif
Source tapes/config.tape
-Type "ipb transactions mock-account-id" Sleep 500ms Enter
+Type "ipb transactions mock-account-id" Sleep 1000ms Enter
Sleep 2s
diff --git a/tapes/transfer.tape b/tapes/transfer.tape
index fc95c03..2ecd9b0 100644
--- a/tapes/transfer.tape
+++ b/tapes/transfer.tape
@@ -1,6 +1,8 @@
Output assets/transfer.gif
Source tapes/config.tape
-Type "ipb transfer mock-account-id mock-account-id 100.00 test-transfer" Sleep 500ms Enter
+Type "ipb transfer mock-account-id mock-account-id 100.00 test-transfer" Sleep 1000ms Enter
+
+Type "y" Sleep 500ms Enter
Sleep 2s
diff --git a/tapes/upload-env.tape b/tapes/upload-env.tape
index 93745c1..890e609 100644
--- a/tapes/upload-env.tape
+++ b/tapes/upload-env.tape
@@ -1,6 +1,6 @@
Output assets/upload-env.gif
Source tapes/config.tape
-Type "ipb upload-env -f env.json -c 1111111" Sleep 500ms Enter
+Type "ipb upload-env -f env.json -c 1111111" Sleep 1000ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/tapes/upload.tape b/tapes/upload.tape
index 69e9838..0c2180b 100644
--- a/tapes/upload.tape
+++ b/tapes/upload.tape
@@ -1,6 +1,6 @@
Output assets/upload.gif
Source tapes/config.tape
-Type "ipb upload -f main.js -c 1111111" Sleep 500ms Enter
+Type "ipb upload -f main.js -c 1111111" Sleep 1000ms Enter
Sleep 5s
\ No newline at end of file
diff --git a/test/cmds/fetch.test.ts b/test/cmds/fetch.test.ts
index cc60dc9..3464630 100644
--- a/test/cmds/fetch.test.ts
+++ b/test/cmds/fetch.test.ts
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fetchCommand } from '../../src/cmds/fetch';
import { CliError, ERROR_CODES } from '../../src/errors';
+
vi.mock('../../src/runtime-credentials.ts', () => ({
credentials: { cardKey: 'default-card-key' },
printTitleBox: vi.fn(),
@@ -62,7 +63,7 @@ vi.mock('node:fs/promises', () => ({
const { initializeApi } = await import('../../src/utils.ts');
const mockApi = {
- getSavedCode: vi.fn(),
+ getCode: vi.fn(),
};
describe('fetchCommand', () => {
@@ -93,42 +94,19 @@ describe('fetchCommand', () => {
},
};
- mockApi.getSavedCode.mockResolvedValue(mockResult);
+ mockApi.getCode.mockResolvedValue(mockResult);
mockFsPromises.writeFile.mockResolvedValue(undefined);
mockFsPromises.stat.mockResolvedValue({ size: Buffer.byteLength(mockCode, 'utf8') });
await fetchCommand(options);
- expect(mockApi.getSavedCode).toHaveBeenCalledWith('test-card-key');
+ expect(mockApi.getCode).toHaveBeenCalledWith('test-card-key');
const { resolve } = await import('node:path');
const expectedPath = resolve('fetched.js');
expect(mockFsPromises.writeFile).toHaveBeenCalledWith(expectedPath, mockCode, 'utf8');
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('code saved to file'));
});
- it('should throw CliError when API does not support getSavedCode', async () => {
- const options = {
- filename: 'fetched.js',
- cardKey: 'test-card-key',
- host: 'test-host',
- apiKey: 'test-api-key',
- clientId: 'test-client-id',
- clientSecret: 'test-client-secret',
- credentialsFile: 'test-credentials-file',
- verbose: false,
- };
-
- const apiWithoutMethod = {};
- (initializeApi as vi.Mock).mockResolvedValue(apiWithoutMethod);
-
- await expect(fetchCommand(options)).rejects.toSatisfy(
- (err: unknown) =>
- err instanceof CliError &&
- err.code === ERROR_CODES.UNSUPPORTED_OPERATION &&
- err.message.includes('API client does not support fetching saved code')
- );
- });
-
it('should throw CliError when API response is invalid', async () => {
const options = {
filename: 'fetched.js',
@@ -142,7 +120,7 @@ describe('fetchCommand', () => {
};
(initializeApi as vi.Mock).mockResolvedValue(mockApi);
- mockApi.getSavedCode.mockResolvedValue({ data: {} });
+ mockApi.getCode.mockResolvedValue({ data: {} });
await expect(fetchCommand(options)).rejects.toSatisfy(
(err: unknown) =>
@@ -172,13 +150,13 @@ describe('fetchCommand', () => {
},
};
- mockApi.getSavedCode.mockResolvedValue(mockResult);
+ mockApi.getCode.mockResolvedValue(mockResult);
mockFsPromises.writeFile.mockResolvedValue(undefined);
mockFsPromises.stat.mockResolvedValue({ size: Buffer.byteLength(mockCode, 'utf8') });
await fetchCommand(options);
- expect(mockApi.getSavedCode).toHaveBeenCalledWith('default-card-key');
+ expect(mockApi.getCode).toHaveBeenCalledWith('default-card-key');
});
it('should propagate file write errors', async () => {
@@ -203,7 +181,7 @@ describe('fetchCommand', () => {
};
const writeError = new Error('Permission denied');
- mockApi.getSavedCode.mockResolvedValue(mockResult);
+ mockApi.getCode.mockResolvedValue(mockResult);
mockFsPromises.writeFile.mockRejectedValue(writeError);
await expect(fetchCommand(options)).rejects.toThrow('Permission denied');
diff --git a/test/cmds/integration-safety.test.ts b/test/cmds/integration-safety.test.ts
index 65d39a8..d42e913 100644
--- a/test/cmds/integration-safety.test.ts
+++ b/test/cmds/integration-safety.test.ts
@@ -30,7 +30,7 @@ const mockState = vi.hoisted(() => ({
text: '',
},
cardApi: {
- getSavedCode: vi.fn(),
+ getCode: vi.fn(),
getCards: vi.fn(),
uploadEnv: vi.fn(),
uploadCode: vi.fn(),
@@ -90,7 +90,7 @@ describe('integration safety net', () => {
mockState.spinner.clear.mockClear();
mockState.spinner.succeed.mockClear();
mockState.spinner.fail.mockClear();
- mockState.cardApi.getSavedCode.mockReset();
+ mockState.cardApi.getCode.mockReset();
mockState.cardApi.getCards.mockReset();
mockState.cardApi.uploadEnv.mockReset();
mockState.cardApi.uploadCode.mockReset();
@@ -274,7 +274,7 @@ describe('integration safety net', () => {
});
it('fetch always cleans spinner on error', async () => {
- mockState.cardApi.getSavedCode.mockRejectedValue(new Error('fetch failed'));
+ mockState.cardApi.getCode.mockRejectedValue(new Error('fetch failed'));
await expect(
fetchCommand({
@@ -296,7 +296,7 @@ describe('integration safety net', () => {
it('fetch does not start spinner when stdout is piped', async () => {
mockState.isPiped = true;
- mockState.cardApi.getSavedCode.mockRejectedValue(new Error('fetch failed'));
+ mockState.cardApi.getCode.mockRejectedValue(new Error('fetch failed'));
await expect(
fetchCommand({
diff --git a/test/cmds/pay-transfer-cli-args.test.ts b/test/cmds/pay-transfer-cli-args.test.ts
new file mode 100644
index 0000000..2fda919
--- /dev/null
+++ b/test/cmds/pay-transfer-cli-args.test.ts
@@ -0,0 +1,176 @@
+///
+
+/**
+ * Ensures pay/transfer amount args stay typed the way Commander delivers them (strings)
+ * and still succeed end-to-end through the real command handlers.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { Command } from 'commander';
+import { payCommand } from '../../src/cmds/pay';
+import { transferCommand } from '../../src/cmds/transfer';
+import { withCommandContext } from '../../src/utils/cli-errors';
+
+const mockUtilsState = vi.hoisted(() => ({
+ confirmed: true,
+ pbApi: {
+ payMultiple: vi.fn(),
+ transferMultiple: vi.fn(),
+ },
+}));
+
+vi.mock('@inquirer/prompts', () => ({
+ input: vi.fn(),
+}));
+
+vi.mock('../../src/runtime-credentials.ts', async () => {
+ const { getRuntimeCredentialsMock } = await import('../helpers/cli-mocks.js');
+ return getRuntimeCredentialsMock();
+});
+
+vi.mock('../../src/utils.ts', async () => {
+ const actual = await vi.importActual('../../src/utils.ts');
+ return {
+ ...actual,
+ confirmDestructiveOperation: vi.fn(async () => mockUtilsState.confirmed),
+ initializePbApi: vi.fn(async () => mockUtilsState.pbApi),
+ isStdoutPiped: vi.fn(() => false),
+ createSpinner: vi.fn(() => ({
+ start: vi.fn(function () {
+ return this;
+ }),
+ stop: vi.fn(),
+ clear: vi.fn(),
+ succeed: vi.fn(),
+ fail: vi.fn(),
+ text: '',
+ })),
+ withRetry: vi.fn(async (fn: () => Promise) => await fn()),
+ withSpinnerOutcome: vi.fn(async (_spinner, _enabled, fn: () => Promise) => await fn()),
+ };
+});
+
+function buildPayProgram(actionSpy: ReturnType) {
+ const program = new Command();
+ program.exitOverride();
+ program
+ .command('pay')
+ .argument('accountId', 'Account ID to pay from')
+ .argument('beneficiaryId', 'Beneficiary ID to pay to')
+ .argument('amount', 'Amount to pay in rands (e.g. 100.00)')
+ .argument('reference', 'Payment reference message')
+ .option('--yes', 'Skip confirmation prompt for destructive operations')
+ .action(
+ withCommandContext('pay', async (...args: Parameters) => {
+ actionSpy(...args);
+ return payCommand(...args);
+ })
+ );
+ return program;
+}
+
+function buildTransferProgram(actionSpy: ReturnType) {
+ const program = new Command();
+ program.exitOverride();
+ program
+ .command('transfer')
+ .argument('accountId', 'Account ID to transfer from')
+ .argument('beneficiaryAccountId', 'Beneficiary account ID to transfer to')
+ .argument('amount', 'Amount to transfer in rands (e.g. 100.00)')
+ .argument('reference', 'Payment reference message')
+ .option('--yes', 'Skip confirmation prompt for destructive operations')
+ .action(
+ withCommandContext('transfer', async (...args: Parameters) => {
+ actionSpy(...args);
+ return transferCommand(...args);
+ })
+ );
+ return program;
+}
+
+describe('Commander amount args for pay/transfer', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUtilsState.confirmed = true;
+ mockUtilsState.pbApi.payMultiple.mockReset();
+ mockUtilsState.pbApi.transferMultiple.mockReset();
+ mockUtilsState.pbApi.payMultiple.mockResolvedValue({
+ data: {
+ TransferResponses: [
+ { BeneficiaryAccountId: 'b1', PaymentReferenceNumber: 'PRN1' },
+ ],
+ },
+ });
+ mockUtilsState.pbApi.transferMultiple.mockResolvedValue({
+ data: {
+ TransferResponses: [{ BeneficiaryAccountId: 'mock-account-id', Status: 'Success' }],
+ },
+ });
+ });
+
+ it('pay parseAsync passes amount as a string and still succeeds', async () => {
+ const actionSpy = vi.fn();
+ const program = buildPayProgram(actionSpy);
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+
+ await program.parseAsync(
+ ['pay', 'mock-account-id', 'b1', '100.00', 'test-payment', '--yes'],
+ { from: 'user' }
+ );
+
+ expect(actionSpy).toHaveBeenCalled();
+ const amountArg = actionSpy.mock.calls[0]?.[2];
+ expect(typeof amountArg).toBe('string');
+ expect(amountArg).toBe('100.00');
+
+ expect(mockUtilsState.pbApi.payMultiple).toHaveBeenCalledWith('mock-account-id', [
+ {
+ beneficiaryId: 'b1',
+ amount: '100',
+ myReference: 'test-payment',
+ theirReference: 'test-payment',
+ },
+ ]);
+ logSpy.mockRestore();
+ });
+
+ it('transfer parseAsync passes amount as a string and still succeeds', async () => {
+ const actionSpy = vi.fn();
+ const program = buildTransferProgram(actionSpy);
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+
+ await program.parseAsync(
+ ['transfer', 'mock-account-id', 'mock-account-id', '100.00', 'test-transfer', '--yes'],
+ { from: 'user' }
+ );
+
+ expect(actionSpy).toHaveBeenCalled();
+ const amountArg = actionSpy.mock.calls[0]?.[2];
+ expect(typeof amountArg).toBe('string');
+ expect(amountArg).toBe('100.00');
+
+ expect(mockUtilsState.pbApi.transferMultiple).toHaveBeenCalledWith('mock-account-id', [
+ {
+ beneficiaryAccountId: 'mock-account-id',
+ amount: '100',
+ myReference: 'test-transfer',
+ theirReference: 'test-transfer',
+ },
+ ]);
+ logSpy.mockRestore();
+ });
+
+ it('pay parseAsync rejects invalid string amounts', async () => {
+ const actionSpy = vi.fn();
+ const program = buildPayProgram(actionSpy);
+
+ await expect(
+ program.parseAsync(['pay', 'mock-account-id', 'b1', 'nope', 'ref', '--yes'], {
+ from: 'user',
+ })
+ ).rejects.toThrow(/valid number/);
+
+ expect(actionSpy).toHaveBeenCalled();
+ expect(typeof actionSpy.mock.calls[0]?.[2]).toBe('string');
+ expect(mockUtilsState.pbApi.payMultiple).not.toHaveBeenCalled();
+ });
+});
diff --git a/test/cmds/pay.test.ts b/test/cmds/pay.test.ts
index dce12e4..aa1e9ea 100644
--- a/test/cmds/pay.test.ts
+++ b/test/cmds/pay.test.ts
@@ -2,7 +2,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { payCommand } from '../../src/cmds/pay';
-import { CliError } from '../../src/errors';
+import { CliError, ERROR_CODES } from '../../src/errors';
const mockInput = vi.hoisted(() => vi.fn());
@@ -26,9 +26,8 @@ vi.mock('../../src/utils.ts', async () => {
const actual = await vi.importActual('../../src/utils.ts');
return {
...actual,
+ // Keep real validateAmount — Commander passes string args and regression depends on it.
confirmDestructiveOperation: vi.fn(async () => mockUtilsState.confirmed),
- validateAccountId: vi.fn(),
- validateAmount: vi.fn(),
initializePbApi: vi.fn(async () => mockUtilsState.pbApi),
isStdoutPiped: vi.fn(() => false),
createSpinner: vi.fn(() => ({
@@ -45,6 +44,16 @@ vi.mock('../../src/utils.ts', async () => {
};
});
+const baseOptions = {
+ host: 'h',
+ apiKey: 'k',
+ clientId: 'c',
+ clientSecret: 's',
+ credentialsFile: '',
+ verbose: false,
+ yes: true,
+};
+
describe('payCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -53,7 +62,7 @@ describe('payCommand', () => {
mockUtilsState.pbApi.payMultiple.mockReset();
});
- it('calls payMultiple with expected payload when confirmed', async () => {
+ it('accepts numeric amounts', async () => {
mockUtilsState.pbApi.payMultiple.mockResolvedValue({
data: {
TransferResponses: [
@@ -63,15 +72,7 @@ describe('payCommand', () => {
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
- await payCommand('acc-001', 'benef-001', 55.25, 'Utilities', {
- host: 'h',
- apiKey: 'k',
- clientId: 'c',
- clientSecret: 's',
- credentialsFile: '',
- verbose: false,
- yes: true,
- });
+ await payCommand('acc-001', 'benef-001', 55.25, 'Utilities', baseOptions);
expect(mockUtilsState.pbApi.payMultiple).toHaveBeenCalledWith('acc-001', [
{
@@ -85,17 +86,58 @@ describe('payCommand', () => {
logSpy.mockRestore();
});
+ it('accepts string amounts the way Commander passes CLI args', async () => {
+ mockUtilsState.pbApi.payMultiple.mockResolvedValue({
+ data: {
+ TransferResponses: [
+ { BeneficiaryAccountId: 'benef-001', PaymentReferenceNumber: 'prn-001' },
+ ],
+ },
+ });
+
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ await payCommand('acc-001', 'benef-001', '100.00', 'test-payment', baseOptions);
+
+ expect(mockUtilsState.pbApi.payMultiple).toHaveBeenCalledWith('acc-001', [
+ {
+ beneficiaryId: 'benef-001',
+ amount: '100',
+ myReference: 'test-payment',
+ theirReference: 'test-payment',
+ },
+ ]);
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Amount: R100.00'));
+ logSpy.mockRestore();
+ });
+
+ it('rejects non-numeric CLI string amounts before calling the API', async () => {
+ await expect(
+ payCommand('acc-001', 'benef-001', 'not-a-number', 'ref', baseOptions)
+ ).rejects.toSatisfy(
+ (err: unknown) =>
+ err instanceof CliError &&
+ err.code === ERROR_CODES.INVALID_INPUT &&
+ err.message.includes('valid number')
+ );
+ expect(mockUtilsState.pbApi.payMultiple).not.toHaveBeenCalled();
+ });
+
+ it('rejects non-positive CLI string amounts', async () => {
+ await expect(payCommand('acc-001', 'benef-001', '0', 'ref', baseOptions)).rejects.toSatisfy(
+ (err: unknown) =>
+ err instanceof CliError &&
+ err.code === ERROR_CODES.INVALID_INPUT &&
+ err.message.includes('positive')
+ );
+ expect(mockUtilsState.pbApi.payMultiple).not.toHaveBeenCalled();
+ });
+
it('does not call payment API when confirmation is rejected', async () => {
mockUtilsState.confirmed = false;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
- await payCommand('acc-001', 'benef-001', 100, 'Test', {
- host: 'h',
- apiKey: 'k',
- clientId: 'c',
- clientSecret: 's',
- credentialsFile: '',
- verbose: false,
+ await payCommand('acc-001', 'benef-001', '100.00', 'Test', {
+ ...baseOptions,
yes: false,
});
@@ -118,15 +160,7 @@ describe('payCommand', () => {
},
});
- await payCommand('', '', 0, '', {
- host: 'h',
- apiKey: 'k',
- clientId: 'c',
- clientSecret: 's',
- credentialsFile: '',
- verbose: false,
- yes: true,
- });
+ await payCommand('', '', 0, '', baseOptions);
expect(mockInput).toHaveBeenCalledTimes(4);
expect(mockUtilsState.pbApi.payMultiple).toHaveBeenCalledWith('acc-007', [
@@ -142,16 +176,6 @@ describe('payCommand', () => {
it('throws when prompted beneficiary is empty', async () => {
mockInput.mockResolvedValueOnce('acc-007').mockResolvedValueOnce(' ');
- await expect(
- payCommand('', '', 0, '', {
- host: 'h',
- apiKey: 'k',
- clientId: 'c',
- clientSecret: 's',
- credentialsFile: '',
- verbose: false,
- yes: true,
- })
- ).rejects.toThrow(CliError);
+ await expect(payCommand('', '', 0, '', baseOptions)).rejects.toThrow(CliError);
});
});
diff --git a/test/cmds/transfer.test.ts b/test/cmds/transfer.test.ts
index f3b2547..1ffd091 100644
--- a/test/cmds/transfer.test.ts
+++ b/test/cmds/transfer.test.ts
@@ -2,6 +2,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { transferCommand } from '../../src/cmds/transfer';
+import { CliError, ERROR_CODES } from '../../src/errors';
const mockInput = vi.hoisted(() => vi.fn());
@@ -55,6 +56,7 @@ vi.mock('../../src/utils.ts', async () => {
const actual = await vi.importActual('../../src/utils.ts');
return {
...actual,
+ // Keep real validateAmount — Commander passes string args and regression depends on it.
isStdoutPiped: vi.fn(() => mockUtilsState.isPiped),
resolveSpinnerState: vi.fn(() => ({
spinnerEnabled: mockUtilsState.spinnerEnabled,
@@ -62,14 +64,22 @@ vi.mock('../../src/utils.ts', async () => {
})),
createSpinner: vi.fn(() => mockUtilsState.spinner),
confirmDestructiveOperation: vi.fn(async () => mockUtilsState.confirmed),
- validateAccountId: vi.fn(),
- validateAmount: vi.fn(),
initializePbApi: vi.fn(async () => mockUtilsState.pbApi),
withRetry: vi.fn(async (fn: () => Promise) => await fn()),
withSpinnerOutcome: vi.fn(async (_spinner, _enabled, fn: () => Promise) => await fn()),
};
});
+const baseOptions = {
+ host: 'h',
+ apiKey: 'k',
+ clientId: 'c',
+ clientSecret: 's',
+ credentialsFile: '',
+ verbose: false,
+ yes: true,
+};
+
describe('transferCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -79,7 +89,7 @@ describe('transferCommand', () => {
mockUtilsState.pbApi.transferMultiple.mockReset();
});
- it('calls transferMultiple with expected payload when confirmed', async () => {
+ it('accepts numeric amounts', async () => {
mockUtilsState.pbApi.transferMultiple.mockResolvedValue({
data: {
TransferResponses: [{ BeneficiaryAccountId: '200002', Status: 'Success' }],
@@ -87,15 +97,7 @@ describe('transferCommand', () => {
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
- await transferCommand('100001', '200002', 125.5, 'Invoice 42', {
- host: 'h',
- apiKey: 'k',
- clientId: 'c',
- clientSecret: 's',
- credentialsFile: '',
- verbose: false,
- yes: true,
- });
+ await transferCommand('100001', '200002', 125.5, 'Invoice 42', baseOptions);
expect(mockUtilsState.pbApi.transferMultiple).toHaveBeenCalledWith('100001', [
{
@@ -109,17 +111,46 @@ describe('transferCommand', () => {
logSpy.mockRestore();
});
+ it('accepts string amounts the way Commander passes CLI args', async () => {
+ mockUtilsState.pbApi.transferMultiple.mockResolvedValue({
+ data: {
+ TransferResponses: [{ BeneficiaryAccountId: '200002', Status: 'Success' }],
+ },
+ });
+
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
+ await transferCommand('100001', '200002', '100.00', 'test-transfer', baseOptions);
+
+ expect(mockUtilsState.pbApi.transferMultiple).toHaveBeenCalledWith('100001', [
+ {
+ beneficiaryAccountId: '200002',
+ amount: '100',
+ myReference: 'test-transfer',
+ theirReference: 'test-transfer',
+ },
+ ]);
+ expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Amount: R100.00'));
+ logSpy.mockRestore();
+ });
+
+ it('rejects non-numeric CLI string amounts before calling the API', async () => {
+ await expect(
+ transferCommand('100001', '200002', 'abc', 'ref', baseOptions)
+ ).rejects.toSatisfy(
+ (err: unknown) =>
+ err instanceof CliError &&
+ err.code === ERROR_CODES.INVALID_INPUT &&
+ err.message.includes('valid number')
+ );
+ expect(mockUtilsState.pbApi.transferMultiple).not.toHaveBeenCalled();
+ });
+
it('does not call transfer API when confirmation is rejected', async () => {
mockUtilsState.confirmed = false;
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
- await transferCommand('100001', '200002', 100, 'Test', {
- host: 'h',
- apiKey: 'k',
- clientId: 'c',
- clientSecret: 's',
- credentialsFile: '',
- verbose: false,
+ await transferCommand('100001', '200002', '100.00', 'Test', {
+ ...baseOptions,
yes: false,
});
@@ -141,15 +172,7 @@ describe('transferCommand', () => {
});
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
- await transferCommand('', '', 0, '', {
- host: 'h',
- apiKey: 'k',
- clientId: 'c',
- clientSecret: 's',
- credentialsFile: '',
- verbose: false,
- yes: true,
- });
+ await transferCommand('', '', 0, '', baseOptions);
expect(mockInput).toHaveBeenCalledTimes(4);
expect(mockUtilsState.pbApi.transferMultiple).toHaveBeenCalledWith('300003', [
diff --git a/test/utils/input-validation.test.ts b/test/utils/input-validation.test.ts
new file mode 100644
index 0000000..b7cc14a
--- /dev/null
+++ b/test/utils/input-validation.test.ts
@@ -0,0 +1,53 @@
+///
+
+import { describe, expect, it } from 'vitest';
+import { CliError, ERROR_CODES } from '../../src/errors';
+import { validateAmount } from '../../src/utils/input-validation';
+
+describe('validateAmount', () => {
+ it('accepts numeric amounts', () => {
+ expect(validateAmount(100)).toBe(100);
+ expect(validateAmount(100.5)).toBe(100.5);
+ });
+
+ it('coerces CLI string amounts from Commander', () => {
+ expect(validateAmount('100.00')).toBe(100);
+ expect(validateAmount('250.75')).toBe(250.75);
+ expect(validateAmount(' 12.5 ')).toBe(12.5);
+ });
+
+ it('rejects non-numeric strings with INVALID_INPUT', () => {
+ expect(() => validateAmount('abc')).toThrow(CliError);
+ try {
+ validateAmount('abc');
+ } catch (err) {
+ expect(err).toMatchObject({
+ code: ERROR_CODES.INVALID_INPUT,
+ message: expect.stringContaining('valid number'),
+ });
+ }
+ });
+
+ it('rejects empty and whitespace-only strings', () => {
+ expect(() => validateAmount('')).toThrow(/valid number/);
+ expect(() => validateAmount(' ')).toThrow(/valid number/);
+ });
+
+ it('rejects non-positive amounts from numbers and strings', () => {
+ expect(() => validateAmount(0)).toThrow(/positive/);
+ expect(() => validateAmount('0')).toThrow(/positive/);
+ expect(() => validateAmount('-10')).toThrow(/positive/);
+ expect(() => validateAmount(-1)).toThrow(/positive/);
+ });
+
+ it('rejects Infinity (the previous Number.isFinite string false-positive path)', () => {
+ expect(() => validateAmount(Number.POSITIVE_INFINITY)).toThrow(/finite number/);
+ // Strings that are finite numbers must NOT hit the finite-number error.
+ expect(validateAmount('100.00')).toBe(100);
+ });
+
+ it('rejects too many decimal places for numbers and strings', () => {
+ expect(() => validateAmount('1.234')).toThrow(/at most 2 decimal/);
+ expect(() => validateAmount(1.234)).toThrow(/at most 2 decimal/);
+ });
+});