Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,84 @@ describe('production mode', () => {
});
});

const SAMPLE_BALANCE = {
source_id: 'csmrpd_001',
name: 'Checking 1234',
type: 'bank_account',
available: { amount: 12500, currency: 'usd' },
current: { amount: 13000, currency: 'usd' },
};

describe('balances list', () => {
it('GETs the Link API endpoint with bearer auth', async () => {
setResponseForUrl('/balances', 200, {
data: [SAMPLE_BALANCE],
has_more: true,
});

const result = await runProdCli('balances', 'list', '--json');

expect(result.exitCode).toBe(0);
expect(lastRequest.method).toBe('GET');
expect(lastRequest.url).toBe('/balances');
expect(lastRequest.headers.authorization).toBe(
'Bearer prod_test_access_token',
);

const output = parseJson(result.stdout) as Record<string, unknown>;
expect(output.has_more).toBe(true);
expect(Array.isArray(output.data)).toBe(true);
const data = output.data as Record<string, unknown>[];
expect(data).toHaveLength(1);
expect(data[0].source_id).toBe('csmrpd_001');
expect(data[0].type).toBe('bank_account');
});

it('forwards pagination flags into the query string', async () => {
setNextResponse(200, {
data: [],
});

const result = await runProdCli(
'balances',
'list',
'--limit',
'5',
'--starting-after',
'csmrpd_cursor',
'--ending-before',
'csmrpd_prev',
'--json',
);

expect(result.exitCode).toBe(0);
expect(lastRequest.url).toContain('/balances?');
expect(lastRequest.url).toContain('limit=5');
expect(lastRequest.url).toContain('starting_after=csmrpd_cursor');
expect(lastRequest.url).toContain('ending_before=csmrpd_prev');
});

it('rejects unauthenticated requests before hitting the API', async () => {
storage.clearAuth();

const result = await runProdCli('balances', 'list', '--json');

expect(result.exitCode).toBe(1);
const output = parseJson(result.stdout) as Record<string, unknown>;
expect(output.code).toBe('NOT_AUTHENTICATED');
expect(String(output.message)).toMatch(/auth login/i);
const balancesRequest = requests.find((r) => r.url === '/balances');
expect(balancesRequest).toBeUndefined();
});

it('does not show balances in root help', async () => {
const result = await runProdCli('--help');

expect(result.exitCode).toBe(0);
expect(result.stdout + result.stderr).not.toContain('balances');
});
});

describe('auth login', () => {
const DEVICE_CODE_RESPONSE = {
device_code: 'test_device_code',
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { createPaymentMethodsCli } from './commands/payment-methods';
import { createReportCli } from './commands/report';
import { createServeCli } from './commands/serve';
import { createShippingAddressCli } from './commands/shipping-address';
import { createBalancesCli } from './commands/balances';
import { createSourcesCli } from './commands/sources';
import { createSpendRequestCli } from './commands/spend-request';
import { createTransactionsCli } from './commands/transactions';
Expand Down Expand Up @@ -76,7 +77,13 @@ const hiddenCli =
authStorage,
envAccessToken,
)
: null;
: requestedCommand === 'balances'
? createBalancesCli(
() => factory.createBalancesResource(),
authStorage,
envAccessToken,
)
: null;
if (hiddenCli) {
process.argv.splice(2, 1);
}
Expand Down
106 changes: 106 additions & 0 deletions packages/cli/src/commands/balances/__tests__/balances.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { BalancesPage, IBalancesResource } from '@stripe/link-sdk';
import { render } from 'ink-testing-library';
import { describe, expect, it, vi } from 'vitest';
import { sanitizeResource } from '../../../utils/resource-factory';
import { BalancesList } from '../list';

const ESCAPE_PAYLOAD = '\x1b[2JEvil\rHidden';
const CLEAN_TEXT = 'EvilHidden';

function makeResource(page: BalancesPage): IBalancesResource {
return sanitizeResource({
listBalances: vi.fn(async () => page),
} as unknown as IBalancesResource);
}

describe('balances list component', () => {
it('renders balance details', async () => {
const resource = makeResource({
data: [
{
source_id: 'csmrpd_1',
name: 'Checking 1234',
type: 'bank_account',
available: { amount: 12500, currency: 'usd' },
current: { amount: 13000, currency: 'usd' },
},
],
});

const { lastFrame } = render(
<BalancesList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Source');
expect(frame).toContain('Type');
expect(frame).toContain('ID');
expect(frame).toContain('Available');
expect(frame).toContain('Current');
expect(frame).toContain('Checking 1234');
expect(frame).toContain('bank_account');
expect(frame).toContain('csmrpd_1');
expect(frame).toContain('12500 usd');
expect(frame).toContain('13000 usd');
});
});

it('renders an empty state when there are no balances', async () => {
const resource = makeResource({ data: [] });

const { lastFrame } = render(
<BalancesList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
expect(lastFrame()).toContain('No balances found');
});
});

it('sanitizes escape sequences in balance fields', async () => {
const resource = makeResource({
data: [
{
source_id: 'csmrpd_1',
name: ESCAPE_PAYLOAD,
type: ESCAPE_PAYLOAD,
},
],
});

const { lastFrame } = render(
<BalancesList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain(CLEAN_TEXT);
expect(frame).not.toContain('\x1b[2J');
expect(frame).not.toContain('\r');
});
});

it('renders pagination metadata when available', async () => {
const resource = makeResource({
data: [
{
source_id: 'csmrpd_2',
name: 'Savings',
type: 'bank_account',
},
],
has_more: true,
});

const { lastFrame } = render(
<BalancesList resource={resource} onComplete={() => {}} />,
);

await vi.waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('has_more: true');
expect(frame).toContain('--starting-after csmrpd_2');
});
});
});
54 changes: 54 additions & 0 deletions packages/cli/src/commands/balances/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type {
AuthStorage,
IBalancesResource,
ListBalancesParams,
} from '@stripe/link-sdk';
import { Cli } from 'incur';
import React from 'react';
import { renderInteractive } from '../../utils/render-interactive';
import { requireAuth } from '../../utils/require-auth';
import { BalancesList } from './list';
import { listOptions } from './schema';

export function createBalancesCli(
createResource: () => IBalancesResource,
authStorage?: AuthStorage,
envAccessToken?: string,
) {
const cli = Cli.create('balances', {
description: 'List balances from your Link wallet',
});

cli.command('list', {
description: 'List balances from your Link wallet',
options: listOptions,
outputPolicy: 'agent-only' as const,
middleware: [requireAuth(authStorage, envAccessToken)],
async run(c) {
const opts = c.options;
const resource = createResource();

const params: ListBalancesParams = {};
if (opts.limit !== undefined) params.limit = opts.limit;
if (opts.startingAfter !== undefined)
params.starting_after = opts.startingAfter;
if (opts.endingBefore !== undefined)
params.ending_before = opts.endingBefore;

if (!c.agent && !c.formatExplicit) {
return renderInteractive(
<BalancesList
resource={resource}
params={params}
onComplete={() => {}}
/>,
() => resource.listBalances(params),
);
}

return resource.listBalances(params);
},
});

return cli;
}
Loading
Loading