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/); + }); +});