Skip to content

Commit fe3dbb0

Browse files
feat(brex): add brex plugin scaffold (#1301)
Co-authored-by: Dhirender Choudhary <Dhirenderchoudhary0001@gmail.com>
1 parent 1a98f6b commit fe3dbb0

19 files changed

Lines changed: 2702 additions & 0 deletions

packages/brex/api.test.ts

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import { AuthMissingError, logEventFromContext } from 'corsair/core';
2+
import {
3+
BREX_API_BASE,
4+
BrexAPIError,
5+
BrexRateLimitError,
6+
makeBrexRequest,
7+
} from './client';
8+
import {
9+
cardStatusPath,
10+
createBrexEndpoint,
11+
resolvePath,
12+
} from './endpoints/factory';
13+
import type { BrexRouteKey } from './endpoints/routes';
14+
import { BREX_ROUTE_KEYS, BREX_ROUTES, getBrexRoute } from './endpoints/routes';
15+
import type { BrexEndpointInput } from './endpoints/types';
16+
import {
17+
BrexEndpointInputSchemas,
18+
BrexEndpointOutputSchemas,
19+
} from './endpoints/types';
20+
import { errorHandlers } from './error-handlers';
21+
import { brex } from './index';
22+
import { resolveBrexOAuthTenantLink } from './oauth-tenant-link';
23+
24+
jest.mock('corsair/core', () => {
25+
class AuthMissingError extends Error {
26+
constructor(plugin: string, authType: string) {
27+
super(`Missing ${authType} for ${plugin}`);
28+
this.name = 'AuthMissingError';
29+
}
30+
}
31+
return {
32+
AuthMissingError,
33+
getOAuthAccessToken: jest.fn(async () => 'oauth-access-token'),
34+
logEventFromContext: jest.fn(),
35+
asRecord: (value: unknown) =>
36+
value !== null && typeof value === 'object'
37+
? (value as Record<string, unknown>)
38+
: null,
39+
firstString: (values: unknown[]) =>
40+
values.find((value) => typeof value === 'string' && value) as
41+
| string
42+
| undefined,
43+
toExternalId: (value: unknown) =>
44+
typeof value === 'string' && value ? value : undefined,
45+
readBodyRecord: (request: { body?: unknown }) =>
46+
request.body !== null &&
47+
typeof request.body === 'object' &&
48+
!Array.isArray(request.body)
49+
? request.body
50+
: null,
51+
};
52+
});
53+
54+
const mockFetch = jest.fn();
55+
56+
beforeAll(() => {
57+
globalThis.fetch = mockFetch as typeof fetch;
58+
});
59+
60+
beforeEach(() => {
61+
mockFetch.mockReset();
62+
jest.mocked(logEventFromContext).mockReset();
63+
mockFetch.mockResolvedValue(
64+
new Response(JSON.stringify({ id: 'ok' }), {
65+
status: 200,
66+
headers: { 'Content-Type': 'application/json' },
67+
}),
68+
);
69+
});
70+
71+
const ctx = { key: 'test-token', $getAccountId: async () => 'acct' } as never;
72+
73+
function jsonResponse(body: unknown, init?: ResponseInit): Response {
74+
return new Response(JSON.stringify(body), {
75+
status: 200,
76+
...init,
77+
headers: {
78+
'Content-Type': 'application/json',
79+
...(init?.headers as Record<string, string> | undefined),
80+
},
81+
});
82+
}
83+
84+
function lastCall() {
85+
expect(mockFetch).toHaveBeenCalled();
86+
const [input, init] = mockFetch.mock.calls[0] as [
87+
string,
88+
RequestInit | undefined,
89+
];
90+
const url = new URL(input);
91+
return {
92+
url: `${url.origin}${url.pathname}`,
93+
path: url.pathname,
94+
method: init?.method ?? 'GET',
95+
auth: new Headers(init?.headers).get('Authorization'),
96+
idempotency: new Headers(init?.headers).get('Idempotency-Key'),
97+
hasSignal: init?.signal instanceof AbortSignal,
98+
};
99+
}
100+
101+
function sampleInput(key: BrexRouteKey): Record<string, unknown> {
102+
const route = BREX_ROUTES[key];
103+
const input: Record<string, unknown> = {};
104+
for (const param of route.pathParams) input[param] = 'id-1';
105+
for (const field of route.required) {
106+
if (field === 'values') input.values = [{ value: 'Engineering' }];
107+
else if (field === 'event_types') input.event_types = ['USER_UPDATED'];
108+
else if (field === 'webhook_ids') input.webhook_ids = ['wh_1'];
109+
else if (field === 'owner') input.owner = { type: 'USER', user_id: 'u1' };
110+
else if (field === 'authorization_settings') {
111+
input.authorization_settings = { type: 'LIMIT' };
112+
} else if (field === 'action') input.action = 'lock';
113+
else if (field === 'min_amount') input.min_amount = 10;
114+
else if (field === 'max_amount') input.max_amount = 50;
115+
else if (field === 'description') input.description = 'uber';
116+
else if (field === 'email') input.email = 'ada@example.com';
117+
else if (field === 'first_name') input.first_name = 'Ada';
118+
else if (field === 'last_name') input.last_name = 'Lovelace';
119+
else if (field === 'type') input.type = 'ARTICLES_OF_INCORPORATION';
120+
else if (field === 'url') input.url = 'https://example.com/hook';
121+
else if (field === 'receipt_name') input.receipt_name = 'receipt.pdf';
122+
else input[field] = 'sample';
123+
}
124+
return input;
125+
}
126+
127+
describe('Brex plugin', () => {
128+
it('registers official auth, host, and every route', () => {
129+
const plugin = brex({ key: 'test-token' });
130+
expect(plugin.id).toBe('brex');
131+
expect(plugin.authConfig?.api_key?.account).toEqual(['company_id']);
132+
expect(plugin.authConfig?.oauth_2?.account).toEqual(['company_id']);
133+
expect(plugin.oauthConfig?.authUrl).toContain('accounts-api.brex.com');
134+
expect(Object.keys(plugin.endpointSchemas ?? {})).toHaveLength(
135+
BREX_ROUTE_KEYS.length,
136+
);
137+
expect(plugin.pluginWebhookMatcher?.({ headers: {} } as never)).toBe(false);
138+
});
139+
140+
it('throws AuthMissingError when no user token is stored', async () => {
141+
const plugin = brex();
142+
await expect(
143+
plugin.keyBuilder?.(
144+
{
145+
authType: 'api_key',
146+
keys: { get_api_key: async () => undefined },
147+
} as never,
148+
'endpoint',
149+
),
150+
).rejects.toThrow(AuthMissingError);
151+
});
152+
153+
it('returns an explicit key from keyBuilder', async () => {
154+
const plugin = brex({ key: 'explicit-token' });
155+
await expect(
156+
plugin.keyBuilder?.(
157+
{ authType: 'api_key', keys: {} } as never,
158+
'endpoint',
159+
),
160+
).resolves.toBe('explicit-token');
161+
});
162+
163+
it.each(BREX_ROUTE_KEYS)(
164+
'%s hits the official path and validates I/O',
165+
async (key) => {
166+
const route = getBrexRoute(key);
167+
const input = BrexEndpointInputSchemas[key].parse(
168+
sampleInput(key),
169+
) as BrexEndpointInput;
170+
if (route.filter === 'transactionId') {
171+
mockFetch.mockResolvedValue(
172+
jsonResponse({
173+
items: [{ id: 'id-1', amount: { amount: 1200 } }],
174+
next_cursor: null,
175+
}),
176+
);
177+
} else if (route.filter === 'transactionAmount') {
178+
mockFetch.mockResolvedValue(
179+
jsonResponse({
180+
items: [
181+
{
182+
id: 't1',
183+
amount: { amount: 2500 },
184+
posted_at_date: '2026-01-02',
185+
},
186+
{
187+
id: 't2',
188+
amount: { amount: 90000 },
189+
posted_at_date: '2026-01-02',
190+
},
191+
],
192+
next_cursor: null,
193+
}),
194+
);
195+
} else if (route.filter === 'transactionDescription') {
196+
mockFetch.mockResolvedValue(
197+
jsonResponse({
198+
items: [
199+
{
200+
id: 't1',
201+
merchant: { raw_descriptor: 'UBER TRIP' },
202+
posted_at_date: '2026-01-02',
203+
},
204+
],
205+
next_cursor: null,
206+
}),
207+
);
208+
}
209+
210+
const result = await createBrexEndpoint(key)(ctx, input);
211+
BrexEndpointOutputSchemas[key].parse(result);
212+
213+
const req = lastCall();
214+
expect(req.url.startsWith(BREX_API_BASE)).toBe(true);
215+
expect(req.auth).toBe('Bearer test-token');
216+
if (route.filter === 'cardStatus') {
217+
expect(req.path).toBe(cardStatusPath('id-1', 'lock'));
218+
} else {
219+
expect(req.path).toBe(resolvePath(route.path, input));
220+
}
221+
expect(req.method).toBe(route.method);
222+
},
223+
);
224+
225+
it('encodes path ids as a single segment', () => {
226+
expect(resolvePath('/v2/cards/{id}', { id: 'a/b?x=1' })).toBe(
227+
'/v2/cards/a%2Fb%3Fx%3D1',
228+
);
229+
});
230+
231+
it('wraps HTTP 429 as BrexRateLimitError with retry metadata', async () => {
232+
mockFetch.mockResolvedValue(
233+
jsonResponse(
234+
{ message: 'slow down' },
235+
{
236+
status: 429,
237+
statusText: 'Too Many Requests',
238+
headers: { 'Retry-After': '2' },
239+
},
240+
),
241+
);
242+
243+
const thrown = await makeBrexRequest('/v2/company', 'token').catch(
244+
(error: unknown) => error,
245+
);
246+
expect(thrown).toBeInstanceOf(BrexRateLimitError);
247+
expect((thrown as BrexRateLimitError).retryAfterMs).toBe(2000);
248+
expect(errorHandlers.RATE_LIMIT_ERROR.match(thrown as Error)).toBe(true);
249+
const handled = await errorHandlers.RATE_LIMIT_ERROR.handler(
250+
thrown as Error,
251+
);
252+
expect(handled.headersRetryAfterMs).toBe(2000);
253+
});
254+
255+
it('sends a stable Idempotency-Key for cards.create', async () => {
256+
await createBrexEndpoint('cardsCreate')(ctx, {
257+
owner: { type: 'USER', user_id: 'u1' },
258+
card_name: 'Ops',
259+
card_type: 'VIRTUAL',
260+
limit_type: 'CARD',
261+
idempotency_key: 'card-create-1',
262+
});
263+
expect(lastCall().idempotency).toBe('card-create-1');
264+
expect(lastCall().hasSignal).toBe(true);
265+
});
266+
267+
it('keeps the continuation cursor after the transaction scan cap', async () => {
268+
for (let page = 0; page < 50; page += 1) {
269+
mockFetch.mockResolvedValueOnce(
270+
jsonResponse({ items: [], next_cursor: `c${page + 1}` }),
271+
);
272+
}
273+
const result = (await createBrexEndpoint('transactionsByAmountRange')(ctx, {
274+
min_amount: 10,
275+
max_amount: 20,
276+
})) as { next_cursor: string | null };
277+
expect(result.next_cursor).toBe('c50');
278+
});
279+
280+
it('wraps HTTP 401 as BrexAPIError', async () => {
281+
mockFetch.mockResolvedValue(
282+
jsonResponse(
283+
{ message: 'invalid token' },
284+
{ status: 401, statusText: 'Unauthorized' },
285+
),
286+
);
287+
await expect(makeBrexRequest('/v2/company', 'bad')).rejects.toBeInstanceOf(
288+
BrexAPIError,
289+
);
290+
});
291+
});
292+
293+
describe('Brex OAuth tenant link', () => {
294+
it('returns null when company lookup fails', async () => {
295+
mockFetch.mockRejectedValueOnce(new Error('network'));
296+
await expect(
297+
resolveBrexOAuthTenantLink({
298+
access_token: 'token',
299+
} as never),
300+
).resolves.toBeNull();
301+
});
302+
});

0 commit comments

Comments
 (0)