Skip to content

Commit 40b27b4

Browse files
vrogojinVladimir Rogojin
andauthored
fix(cli)(#40): bulk-return amount display + integration test scrub (#41)
Closes follow-ups #1 and #2 from issue #40. Item #1 — `sphere invoice return <id>` (bulk) shows actual refunded amount instead of `amount: "0"`. The bug: `getInvoiceStatus()` ran AFTER `returnAllInvoicePayments()`, by which point the SDK had drained `senderBalances[].netBalance` to 0 (refunds had been issued), so every row in the rendered output showed 0. Fix: snapshot status BEFORE the SDK call, mirror the SDK's iteration filters so `plan[i] ↔ results[i]`. Static-analysis pin in `legacy-cli-ux.test.ts` asserts the call ordering doesn't regress (first `await getInvoiceStatus(` must precede first `await returnAllInvoicePayments(` inside the invoice-return case). Item #2 — integration test backlog from PR #33 canonical-UX cutover. Down from 63 → 3 failures (remaining 3 are unrelated regressions, called out below). • `expectUsageHint(out, cmdName, positional?)` helper in `test/integration/helpers.ts`: tolerates the new `Usage: npm run cli -- <cmd>` prefix so the next help-format change is a one-line fix. All ~13 call sites updated. • JSON-shape stdout captures (`/"directAddress": "..."/` etc.) updated to canonical-UX labelled-block form (`/directAddress\s*:\s*.../`) across 9 test files. • `--asset "1000000 UCT"` quoted single-string form (rejected by the canonical asset-pair guard) replaced with two-positional form in cli-invoice/cli-swap. • `expect.toMatch(/Asset not found/)` → case-insensitive — the error string is lowercased by `failWithHelp` now. Same for `No invoice found matching prefix:`. • `crypto decrypt` now accepts BOTH bare base64 (the new canonical-UX `crypto encrypt` output) and JSON-quoted base64 (legacy `--json` callers). Restores the `encrypt foo pw | decrypt - pw` roundtrip. • Added `swap-ping` HELP_TEXT block and completion entry (the `failWithHelp('swap-ping', ...)` calls were already there; only the help registration was missing). Moved swap-ping into `SWAP_SUBCOMMANDS` and dropped the obsolete "no help available (HELP_TEXT gap pin)" test. Items #3 (`--asset-index` / `--target-index`) and #4 (NFT-only `invoice pay`) deferred per the issue's suggested order. Out of scope, remaining integration failures (3): • `cli-multiaddress`: `tokens/` subdir ENOENT after `payments switch 1` — likely Profile (OrbitDB) cutover relocating the per-address token store off `.sphere-cli/tokens/`. Separate bug; not Usage/UX cutover. • `cli-wallet-lifecycle`: `sphere clear --yes` succeeds, but `sphere status` afterwards auto-generates a new wallet and reports it instead of "No wallet found". Real CLI/SDK regression; needs a separate ticket. • `cli-wallet-lifecycle`: `init --nametag` flake — testnet nametag mint sometimes doesn't complete inside the timeout. Locally green when the mint succeeds; the regex now correctly matches the canonical UX `nametag : @<name>` form. Unit tests (127) all green. Type-check + lint clean. Co-authored-by: Vladimir Rogojin <vrogojin@blockyinnovations.com>
1 parent bb0c405 commit 40b27b4

17 files changed

Lines changed: 271 additions & 139 deletions

src/legacy/legacy-cli-ux.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,52 @@ describe('issue #32 — UX consistency', () => {
127127
expect(SOURCE).toMatch(/args\.includes\('--help'\)\s*\|\|\s*args\.includes\('-h'\)/);
128128
});
129129
});
130+
131+
describe('issue #40 item #1 — bulk-return amount display', () => {
132+
// Static-analysis pin for the bulk-refund render bug:
133+
//
134+
// `sphere invoice return <id>` (no flags / --recipient form) used to
135+
// call `getInvoiceStatus()` AFTER `returnAllInvoicePayments()`. By
136+
// that point the SDK had drained `senderBalances[].netBalance` to 0
137+
// (refunds had been issued), so every refund row in the rendered
138+
// output showed `amount: "0"`. Fix: snapshot status BEFORE the SDK
139+
// call so we render the actual refunded amounts.
140+
//
141+
// Pin: inside the `case 'invoice-return':` block, the first
142+
// `getInvoiceStatus(` call must appear BEFORE the first
143+
// `returnAllInvoicePayments(` call. Source ordering reflects
144+
// execution ordering here — both are top-level `await`s, no
145+
// conditional skips between them.
146+
147+
it('getInvoiceStatus is called BEFORE returnAllInvoicePayments in invoice-return', () => {
148+
const caseStart = SOURCE.indexOf("case 'invoice-return':");
149+
expect(caseStart, "case 'invoice-return': not found").toBeGreaterThan(-1);
150+
151+
// Locate end of case — next top-level `case '...'` or `default:` token.
152+
const after = SOURCE.slice(caseStart + 1);
153+
const nextCaseRel = after.search(/\n {6}case '[^']+':|\n {6}default:/);
154+
const caseEnd = nextCaseRel >= 0 ? caseStart + 1 + nextCaseRel : SOURCE.length;
155+
const block = SOURCE.slice(caseStart, caseEnd);
156+
157+
// Match the actual `await sphere.accounting!.<method>(` call sites,
158+
// not bare `<method>(` (would also match comment references like
159+
// "iterates `getInvoiceStatus().senderBalances` internally").
160+
const statusCallRe = /await\s+sphere\.accounting!?\.getInvoiceStatus\s*\(/;
161+
const returnAllRe = /await\s+sphere\.accounting!?\.returnAllInvoicePayments\s*\(/;
162+
const statusMatch = statusCallRe.exec(block);
163+
const returnAllMatch = returnAllRe.exec(block);
164+
165+
expect(
166+
statusMatch,
167+
'getInvoiceStatus() call missing from invoice-return — bulk-refund render needs the pre-refund snapshot.',
168+
).not.toBeNull();
169+
expect(
170+
returnAllMatch,
171+
'returnAllInvoicePayments() call missing from invoice-return — Form B/C delegates to the SDK.',
172+
).not.toBeNull();
173+
expect(
174+
statusMatch!.index,
175+
'getInvoiceStatus() must be called BEFORE returnAllInvoicePayments() so renderer shows pre-refund amounts (issue #40 item #1).',
176+
).toBeLessThan(returnAllMatch!.index);
177+
});
178+
});

src/legacy/legacy-cli.ts

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1709,6 +1709,18 @@ const COMMAND_HELP: Record<string, CommandHelp> = {
17091709
'Accepts full 64-char swap ID or a unique prefix (min 4 chars).',
17101710
],
17111711
},
1712+
'swap-ping': {
1713+
usage: 'swap-ping <@nametag_or_address>',
1714+
description: 'Ping an escrow service to verify it is online and reachable via the configured transport.',
1715+
examples: [
1716+
'npm run cli -- swap-ping @escrow',
1717+
'npm run cli -- swap-ping DIRECT://0000abcd...',
1718+
],
1719+
notes: [
1720+
'Accepts an @nametag, DIRECT://… address, or any other identifier swap-propose accepts.',
1721+
'On success, prints the escrow\'s pong response (transport pubkey, capabilities).',
1722+
],
1723+
},
17121724

17131725
'swap-reject': {
17141726
usage: 'swap-reject <swap_id_or_prefix> [reason]',
@@ -3533,9 +3545,21 @@ async function main(): Promise<void> {
35333545
case 'decrypt': {
35343546
const [, encrypted, password] = args;
35353547
if (!encrypted || !password) {
3536-
failWithHelp('decrypt', 'missing required <encrypted-json> <password> arguments');
3548+
failWithHelp('decrypt', 'missing required <encrypted> <password> arguments');
3549+
}
3550+
// Accept both shapes: bare base64 (what `crypto encrypt` now
3551+
// emits under canonical UX) and JSON-quoted base64 (legacy
3552+
// wrapper, kept for backward compat with scripts that pipe
3553+
// `encrypt --json` output). Trying JSON.parse first preserves
3554+
// the legacy roundtrip; falling back to the raw string makes
3555+
// the natural `crypto encrypt | xargs crypto decrypt` pipeline
3556+
// work without manual quoting.
3557+
let encryptedData: string;
3558+
if (encrypted.startsWith('"') && encrypted.endsWith('"')) {
3559+
encryptedData = JSON.parse(encrypted) as string;
3560+
} else {
3561+
encryptedData = encrypted;
35373562
}
3538-
const encryptedData = JSON.parse(encrypted);
35393563
const result = decrypt(encryptedData, password);
35403564
console.log(result);
35413565
break;
@@ -4961,38 +4985,51 @@ async function main(): Promise<void> {
49614985
if (explicitRecipient) {
49624986
sdkOptions.recipient = await resolveRecipientToDirect(sphere, explicitRecipient, 'invoice-return');
49634987
}
4988+
4989+
// Snapshot per-sender balances BEFORE the SDK call. The SDK drains
4990+
// `senderBalances[].netBalance` as it refunds, so re-reading status
4991+
// afterwards produces `amount: 0` rows (issue #40 item #1). Mirror
4992+
// the SDK's iteration order + filters so plan[i] ↔ results[i].
4993+
const statusBefore = await sphere.accounting!.getInvoiceStatus(invoiceId);
4994+
const plan: Array<{ recipient: string; coinId: string; netBalance: string }> = [];
4995+
for (const target of statusBefore.targets) {
4996+
for (const ca of target.coinAssets) {
4997+
const [coinId] = ca.coin;
4998+
for (const sb of ca.senderBalances) {
4999+
let bal: bigint;
5000+
try {
5001+
bal = BigInt(sb.netBalance);
5002+
} catch {
5003+
continue;
5004+
}
5005+
if (bal <= 0n) continue;
5006+
if (sdkOptions.recipient !== undefined && sb.senderAddress !== sdkOptions.recipient) continue;
5007+
plan.push({ recipient: sb.senderAddress, coinId, netBalance: sb.netBalance });
5008+
}
5009+
}
5010+
}
5011+
49645012
const results = await sphere.accounting!.returnAllInvoicePayments(invoiceId, sdkOptions);
49655013
if (results.length === 0) {
49665014
const reason = explicitRecipient
49675015
? `no refundable balance for recipient "${explicitRecipient}" on invoice ${invoiceId.slice(0, 16)}…`
49685016
: `no refundable balance found on invoice ${invoiceId.slice(0, 16)}… — nothing to return`;
49695017
failWithHelp('invoice-return', reason);
49705018
}
4971-
// The SDK returns TransferResult per row but doesn't echo back the
4972-
// (recipient, amount, coin) tuple — re-attach those from status so
4973-
// the human renderer can show what got refunded where.
4974-
const status = await sphere.accounting!.getInvoiceStatus(invoiceId);
5019+
49755020
const refundRows: Array<Record<string, unknown>> = [];
4976-
let cursor = 0;
4977-
for (const target of status.targets) {
4978-
for (const ca of target.coinAssets) {
4979-
const [coinId] = ca.coin;
4980-
const { decimals } = resolveCoin(coinId);
4981-
for (const sb of ca.senderBalances) {
4982-
// Skip rows that returnAllInvoicePayments wouldn't have
4983-
// refunded (zero balance, or recipient filter).
4984-
if (sdkOptions.recipient !== undefined && sb.senderAddress !== sdkOptions.recipient) continue;
4985-
if (cursor >= results.length) break;
4986-
const result = results[cursor++];
4987-
refundRows.push({
4988-
id: result.id,
4989-
status: result.status,
4990-
recipient: sb.senderAddress,
4991-
amount: toHumanReadable(sb.netBalance, decimals),
4992-
coin: coinId,
4993-
});
4994-
}
4995-
}
5021+
const rowCount = Math.min(plan.length, results.length);
5022+
for (let i = 0; i < rowCount; i++) {
5023+
const row = plan[i];
5024+
const result = results[i];
5025+
const { decimals } = resolveCoin(row.coinId);
5026+
refundRows.push({
5027+
id: result.id,
5028+
status: result.status,
5029+
recipient: row.recipient,
5030+
amount: toHumanReadable(row.netBalance, decimals),
5031+
coin: row.coinId,
5032+
});
49965033
}
49975034
formatOutput(
49985035
{ refunds: refundRows } as Record<string, unknown>,
@@ -5787,6 +5824,7 @@ function getCompletionCommands(): CompletionCommand[] {
57875824
{ name: 'swap-accept', description: 'Accept a swap deal', flags: ['--deposit', '--no-wait'] },
57885825
{ name: 'swap-status', description: 'Show swap status', flags: ['--query-escrow'] },
57895826
{ name: 'swap-deposit', description: 'Deposit into a swap' },
5827+
{ name: 'swap-ping', description: 'Ping an escrow service' },
57905828
{ name: 'swap-reject', description: 'Reject a swap proposal' },
57915829
{ name: 'swap-cancel', description: 'Cancel a swap' },
57925830
{ name: 'market-post', description: 'Post a market intent', flags: ['--type', '--category', '--price', '--currency', '--location', '--contact', '--expires'] },

test/integration/cli-assets.integration.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
4545
import {
4646
createSphereEnv,
4747
destroySphereEnv,
48+
expectUsageHint,
4849
runSphere,
4950
integrationSkip,
5051
type SphereEnv,
@@ -92,8 +93,7 @@ describe('sphere-cli — asset-info arg validation (offline)', () => {
9293
// "did I type the right command" probe offline-fast for users.
9394
const r = runSphere(env, ['payments', 'asset-info'], { timeoutMs: 15_000 });
9495
expect(r.status).not.toBe(0);
95-
const out = `${r.stdout}\n${r.stderr}`;
96-
expect(out).toMatch(/Usage:\s*asset-info\s*<symbol\|name\|coinId>/i);
96+
expectUsageHint(`${r.stdout}\n${r.stderr}`, 'asset-info', '<symbol|name|coinId>');
9797
});
9898
});
9999

@@ -175,7 +175,10 @@ describe.skipIf(integrationSkip)(
175175
const r = runSphere(env, ['payments', 'asset-info', 'NOT_A_REAL_TOKEN_ZZZ'], { timeoutMs: 120_000 });
176176
expect(r.status).not.toBe(0);
177177
const out = `${r.stdout}\n${r.stderr}`;
178-
expect(out).toMatch(/Asset not found/);
178+
// Case-insensitive because failWithHelp lowercases its prose
179+
// ("asset not found: ...") — the asserted invariant is the
180+
// negative-lookup verdict reaching the user, not the casing.
181+
expect(out).toMatch(/asset not found/i);
179182
}, 180_000);
180183
},
181184
);

test/integration/cli-crypto.integration.test.ts

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3737
import {
3838
createSphereEnv,
3939
destroySphereEnv,
40+
expectUsageHint,
4041
runSphere,
4142
type SphereEnv,
4243
} from './helpers.js';
@@ -113,8 +114,7 @@ describe('sphere-cli — crypto/util arg validation (offline)', () => {
113114
])('`sphere crypto %s` with no args prints usage and exits non-zero', (sub, legacyName) => {
114115
const r = runSphere(env, ['crypto', sub], { timeoutMs: 15_000 });
115116
expect(r.status).not.toBe(0);
116-
const out = `${r.stdout}\n${r.stderr}`;
117-
expect(out).toMatch(new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i'));
117+
expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName);
118118
});
119119

120120
it.each([
@@ -124,17 +124,15 @@ describe('sphere-cli — crypto/util arg validation (offline)', () => {
124124
])('`sphere util %s` with no args prints usage and exits non-zero', (sub, legacyName) => {
125125
const r = runSphere(env, ['util', sub], { timeoutMs: 15_000 });
126126
expect(r.status).not.toBe(0);
127-
const out = `${r.stdout}\n${r.stderr}`;
128-
expect(out).toMatch(new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i'));
127+
expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName);
129128
});
130129

131130
it('`sphere crypto encrypt foo` (missing password) prints usage and exits non-zero', () => {
132131
// encrypt + decrypt require TWO positionals — missing the second
133132
// also hits the pre-getSphere() guard.
134133
const r = runSphere(env, ['crypto', 'encrypt', 'foo'], { timeoutMs: 15_000 });
135134
expect(r.status).not.toBe(0);
136-
const out = `${r.stdout}\n${r.stderr}`;
137-
expect(out).toMatch(/Usage:\s*encrypt|usage:\s*encrypt/i);
135+
expectUsageHint(`${r.stdout}\n${r.stderr}`, 'encrypt');
138136
});
139137
});
140138

@@ -187,17 +185,21 @@ describe('sphere-cli — crypto behaviour (offline)', () => {
187185
it('`sphere crypto validate-key <hex>` accepts a valid private key', () => {
188186
const r = runSphere(env, ['crypto', 'validate-key', TEST_PRIVKEY]);
189187
expect(r.status).toBe(0);
190-
// Output is JSON: {"valid":true,"length":64}
191-
expect(r.stdout).toMatch(/"valid":\s*true/);
192-
expect(r.stdout).toMatch(/"length":\s*64/);
188+
// Canonical-UX output is human-friendly labelled blocks
189+
// ` valid : true`
190+
// ` length : 64`
191+
// Pad-aware regex (zero-or-more whitespace around `:`) so future
192+
// alignment tweaks don't rip these out.
193+
expect(r.stdout).toMatch(/valid\s*:\s*true/);
194+
expect(r.stdout).toMatch(/length\s*:\s*64/);
193195
});
194196

195197
it('`sphere crypto validate-key not-hex` rejects an invalid key', () => {
196198
// Per the help text: "Exits with code 0 if valid, 1 if invalid."
197-
// So we expect non-zero exit AND a "valid":false JSON.
199+
// So we expect non-zero exit AND `valid : false` in the output.
198200
const r = runSphere(env, ['crypto', 'validate-key', 'not-hex']);
199201
expect(r.status).not.toBe(0);
200-
expect(r.stdout).toMatch(/"valid":\s*false/);
202+
expect(r.stdout).toMatch(/valid\s*:\s*false/);
201203
});
202204

203205
it('`sphere crypto hex-to-wif` produces a stable WIF for the test private key', () => {
@@ -274,17 +276,17 @@ describe('sphere-cli — util behaviour (offline)', () => {
274276
});
275277

276278
it('`sphere crypto encrypt` / `decrypt` roundtrips a string with a password', () => {
277-
// Pin the AES envelope: encrypt produces a JSON-quoted base64 blob
279+
// Pin the AES envelope: encrypt produces a base64 blob
278280
// ("U2FsdGVkX1+..."), decrypt restores the original plaintext.
279281
// Failure mode pinned by a regression: a change in the cipher,
280282
// salt format, or PBKDF2 iteration count would either fail the
281283
// decrypt or yield a different plaintext.
282284
//
283-
// IMPORTANT: decrypt's first positional MUST be the JSON-quoted
284-
// form ("U2Fsd..."), not the bare base64. The handler runs
285-
// JSON.parse on argv[1] to extract the string. Stripping the
286-
// surrounding quotes here would make decrypt fail with
287-
// "Unexpected token 'U', ... is not valid JSON".
285+
// Canonical UX: `crypto encrypt` emits bare base64 (no JSON
286+
// wrapping) so `crypto encrypt foo pw | crypto decrypt - pw`
287+
// pipelines naturally. `crypto decrypt` accepts both the bare form
288+
// AND the JSON-quoted legacy form, so scripts piping `--json`
289+
// output continue to work.
288290
const plaintext = 'integration-test-secret';
289291
const password = 'p4ssw0rd-test';
290292
const enc = runSphere(env, ['crypto', 'encrypt', plaintext, password]);
@@ -293,9 +295,9 @@ describe('sphere-cli — util behaviour (offline)', () => {
293295
expect(ciphertext.length).toBeGreaterThan(2);
294296
// OpenSSL-compatible AES envelope starts with the magic "Salted__"
295297
// header, base64-encoded as "U2FsdGVkX1" (zero-pad). Pin the prefix
296-
// (inside the quote) so a switch to a non-OpenSSL-compatible scheme
297-
// breaks compat-hungry downstream tooling.
298-
expect(ciphertext).toMatch(/^"U2FsdGVkX1/);
298+
// so a switch to a non-OpenSSL-compatible scheme breaks
299+
// compat-hungry downstream tooling.
300+
expect(ciphertext).toMatch(/^U2FsdGVkX1/);
299301

300302
const dec = runSphere(env, ['crypto', 'decrypt', ciphertext, password]);
301303
expect(dec.status).toBe(0);

test/integration/cli-dm.integration.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ describe.skipIf(integrationSkip)('sphere-cli integration — DM round-trip (real
4848
throw new Error('wallet init failed; cannot proceed with DM tests');
4949
}
5050

51-
// Identity JSON is emitted as pretty-printed JSON inside the init output.
52-
// Extract directAddress with a lenient regex (order-independent of other fields).
53-
const match = init.stdout.match(/"directAddress":\s*"(DIRECT:\/\/[0-9a-fA-F]+)"/);
51+
// Canonical UX init emits ` directAddress : DIRECT://...` as a
52+
// labelled line inside the identity block.
53+
const match = init.stdout.match(/directAddress\s*:\s*(DIRECT:\/\/[0-9a-fA-F]+)/);
5454
if (!match) throw new Error(`directAddress not found in init output:\n${init.stdout}`);
5555
directAddress = match[1]!;
5656
}, 180_000);

test/integration/cli-group.integration.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3939
import {
4040
createSphereEnv,
4141
destroySphereEnv,
42+
expectUsageHint,
4243
runSphere,
4344
type SphereEnv,
4445
} from './helpers.js';
@@ -108,16 +109,14 @@ describe('sphere-cli — group arg validation (offline)', () => {
108109
])('`sphere group %s` with no args prints usage and exits non-zero', (sub, legacyName) => {
109110
const r = runSphere(env, ['group', sub], { timeoutMs: 15_000 });
110111
expect(r.status).not.toBe(0);
111-
const out = `${r.stdout}\n${r.stderr}`;
112-
expect(out).toMatch(new RegExp(`Usage:\\s*${legacyName}|usage:\\s*${legacyName}`, 'i'));
112+
expectUsageHint(`${r.stdout}\n${r.stderr}`, legacyName);
113113
});
114114

115115
it('`sphere group send <groupId>` (missing message) prints usage and exits non-zero', () => {
116116
// group-send requires TWO positionals; missing the second also
117117
// hits the pre-getSphere() guard.
118118
const r = runSphere(env, ['group', 'send', '00deadbeef'], { timeoutMs: 15_000 });
119119
expect(r.status).not.toBe(0);
120-
const out = `${r.stdout}\n${r.stderr}`;
121-
expect(out).toMatch(/Usage:\s*group-send|usage:\s*group-send/i);
120+
expectUsageHint(`${r.stdout}\n${r.stderr}`, 'group-send');
122121
});
123122
});

0 commit comments

Comments
 (0)