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
70 changes: 70 additions & 0 deletions packages/cli/src/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,76 @@ describe('production mode', () => {
});
});

describe('spend-request list', () => {
it('sends GET to /spend_requests and returns the API response as JSON output', async () => {
const requests_list = [
{ ...BASE_REQUEST, id: 'lsrq_001', status: 'approved' },
{ ...BASE_REQUEST, id: 'lsrq_002', status: 'pending_approval' },
{ ...BASE_REQUEST, id: 'lsrq_003', status: 'created' },
];
setNextResponse(200, { data: requests_list });

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

expect(result.exitCode).toBe(0);
expect(lastRequest.method).toBe('GET');
expect(lastRequest.url).toBe('/spend_requests');
expect(lastRequest.headers.authorization).toBe(
'Bearer prod_test_access_token',
);
const output = parseJson(result.stdout) as unknown[];
expect(output).toHaveLength(3);
expect((output[0] as Record<string, unknown>).id).toBe('lsrq_001');
expect((output[0] as Record<string, unknown>).status).toBe('approved');
});

it('returns an empty array when there are no active spend requests', async () => {
setNextResponse(200, { data: [] });

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

expect(result.exitCode).toBe(0);
const output = parseJson(result.stdout) as unknown[];
expect(output).toHaveLength(0);
});

it('surfaces API errors for list', async () => {
setNextResponse(403, { error: { message: 'Forbidden' } });

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

expect(result.exitCode).toBe(1);
const output = result.stdout + result.stderr;
expect(output).toContain('Forbidden');
});

it('exits non-zero with auth error when not logged in', async () => {
storage.clearAll();

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

expect(result.exitCode).toBe(1);
const output = result.stdout + result.stderr;
expect(output).toMatch(/not logged in|auth|login|token/i);
});

it('unwraps data envelope so output is a flat array, not an object with a data key', async () => {
const requests_list = [
{ ...BASE_REQUEST, id: 'lsrq_flat_001', status: 'approved' },
];
setNextResponse(200, { data: requests_list });

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

expect(result.exitCode).toBe(0);
const output = parseJson(result.stdout);
expect(Array.isArray(output)).toBe(true);
const arr = output as Record<string, unknown>[];
expect(arr[0].id).toBe('lsrq_flat_001');
expect((output as Record<string, unknown>).data).toBeUndefined();
});
});

describe('spend-request retrieve', () => {
it('sends GET to /spend-requests/:id', async () => {
const result = await runProdCli(
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/commands/spend-request/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { renderInteractive } from '../../utils/render-interactive';
import { requireAuth, requireAuthGuard } from '../../utils/require-auth';
import { CancelSpendRequest } from './cancel';
import { CreateSpendRequest } from './create';
import { SpendRequestList } from './list';
import { RequestApproval } from './request-approval';
import { RetrieveSpendRequest } from './retrieve';
import { createOptions, retrieveOptions, updateOptions } from './schema';
Expand Down Expand Up @@ -54,6 +55,23 @@ export function createSpendRequestCli(
description: 'Spend request management commands',
});

cli.command('list', {
description:
'List active spend requests (created, pending_approval, approved)',
outputPolicy: 'agent-only' as const,
middleware: [requireAuth(authStorage)],
async run(c) {
if (!c.agent && !c.formatExplicit) {
return renderInteractive(
<SpendRequestList repository={repository} onComplete={() => {}} />,
() => repository.listSpendRequests(),
);
}

return repository.listSpendRequests();
},
});

cli.command('create', {
description: 'Create a new spend request',
options: createOptions,
Expand Down
91 changes: 91 additions & 0 deletions packages/cli/src/commands/spend-request/list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ISpendRequestResource, SpendRequest } from '@stripe/link-sdk';
import { Box, Text, useApp } from 'ink';
import Spinner from 'ink-spinner';
import type React from 'react';
import { useCallback } from 'react';
import { useAsyncAction } from '../../hooks/use-async-action';

interface SpendRequestListProps {
repository: ISpendRequestResource;
onComplete: (result: SpendRequest[] | null) => void;
}

export const SpendRequestList: React.FC<SpendRequestListProps> = ({
repository,
onComplete,
}) => {
const { exit } = useApp();
const action = useCallback(
() => repository.listSpendRequests(),
[repository],
);
const wrappedOnComplete = useCallback(
(result: SpendRequest[] | null) => {
onComplete(result);
exit();
},
[onComplete, exit],
);
const { status, data: requests, error } = useAsyncAction(
action,
wrappedOnComplete,
);

if (status === 'loading') {
return (
<Box>
<Text color="cyan">
<Spinner type="dots" /> Loading spend requests...
</Text>
</Box>
);
}

if (status === 'error') {
return (
<Box flexDirection="column">
<Text color="red">✗ Failed to load spend requests</Text>
<Text color="red">{error}</Text>
</Box>
);
}

if (!requests || requests.length === 0) {
return (
<Box>
<Text dimColor>No active spend requests found</Text>
</Box>
);
}

return (
<Box flexDirection="column">
<Text bold>Active Spend Requests</Text>
<Box flexDirection="column" marginTop={1}>
{requests.map((sr) => {
const statusColor =
sr.status === 'approved'
? 'green'
: sr.status === 'pending_approval'
? 'yellow'
: 'white';
const amount =
sr.amount != null
? `$${(sr.amount / 100).toFixed(2)} ${(sr.currency ?? 'usd').toUpperCase()}`
: '';
return (
<Box key={sr.id} paddingX={2}>
<Text>
<Text dimColor>{sr.id}</Text>
{' '}
<Text color={statusColor}>{sr.status}</Text>
{sr.merchant_name ? ` ${sr.merchant_name}` : ''}
{amount ? ` ${amount}` : ''}
</Text>
</Box>
);
})}
</Box>
</Box>
);
};
55 changes: 55 additions & 0 deletions packages/sdk/src/resources/__tests__/spend-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,4 +415,59 @@ describe('SpendRequestResource', () => {
);
});
});

describe('listSpendRequests', () => {
it('sends GET to correct endpoint with Bearer auth header', async () => {
mockFetchResponse(200, { data: [spendRequestResponse] });

await repo.listSpendRequests();

expect(mockFetch).toHaveBeenCalledOnce();
const [url, opts] = mockFetch.mock.calls[0];
expect(url).toBe('https://api.link.com/spend_requests');
expect(opts.method).toBe('GET');
expect(opts.headers.Authorization).toBe('Bearer test_token');
expect(opts.body).toBeUndefined();
});

it('returns array of SpendRequests unwrapped from data envelope', async () => {
const requests = [
{ ...spendRequestResponse, id: 'si_001', status: 'approved' },
{ ...spendRequestResponse, id: 'si_002', status: 'pending_approval' },
{ ...spendRequestResponse, id: 'si_003', status: 'created' },
];
mockFetchResponse(200, { data: requests });

const result = await repo.listSpendRequests();

expect(result).toHaveLength(3);
expect(result[0].id).toBe('si_001');
expect(result[1].id).toBe('si_002');
expect(result[2].id).toBe('si_003');
});

it('returns empty array when no active spend requests', async () => {
mockFetchResponse(200, { data: [] });

const result = await repo.listSpendRequests();

expect(result).toEqual([]);
});

it('throws on HTTP error with message from body', async () => {
mockFetchResponse(403, { error: { message: 'Forbidden' } });

await expect(repo.listSpendRequests()).rejects.toThrow(
'Failed to list spend requests (403): Forbidden',
);
});

it('throws when no access token is available', async () => {
getAccessToken.mockRejectedValueOnce(new Error('Missing access token'));

await expect(repo.listSpendRequests()).rejects.toThrow(
'Missing access token',
);
});
});
});
2 changes: 2 additions & 0 deletions packages/sdk/src/resources/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export interface UpdateSpendRequestParams {
}

export interface ISpendRequestResource {
list(): Promise<SpendRequest[]>;
listSpendRequests(): Promise<SpendRequest[]>;
create(params: CreateSpendRequestParams): Promise<SpendRequest>;
createSpendRequest(params: CreateSpendRequestParams): Promise<SpendRequest>;
update(id: string, params: UpdateSpendRequestParams): Promise<SpendRequest>;
Expand Down
22 changes: 22 additions & 0 deletions packages/sdk/src/resources/spend-request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,28 @@ export class SpendRequestResource implements ISpendRequestResource {
return res;
}

list(): Promise<SpendRequest[]> {
return this.listSpendRequests();
}

async listSpendRequests(): Promise<SpendRequest[]> {
const { status, data, rawBody } = await this.apiFetch({
method: 'GET',
url: this.spendRequestsEndpoint,
});

if (status < 200 || status >= 300) {
throw new LinkApiError(
`Failed to list spend requests (${status}): ${extractApiError(data, rawBody)}`,
{ status, rawBody, details: data },
);
}

const body = data as Record<string, unknown> | null;
const items = (body?.data as unknown[] | undefined) ?? [];
return items.map(normalizeSpendRequest);
}

create(params: CreateSpendRequestParams): Promise<SpendRequest> {
return this.createSpendRequest(params);
}
Expand Down
Loading