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
155 changes: 155 additions & 0 deletions packages/byteforms/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { makeByteFormsRequest } from './client';
import { ByteFormsEndpointOutputSchemas } from './endpoints/types';

const liveApiKey = process.env.BYTEFORMS_API_KEY ?? '';
const describeLive = liveApiKey ? describe : describe.skip;

describeLive('ByteForms live API', () => {
const key = liveApiKey;
let createdFormId: number | undefined;
const uniqueName = `corsair-live-test-${Date.now()}`;

afterAll(async () => {
// Cleanup: remove any form this suite created.
if (createdFormId !== undefined) {
try {
await makeByteFormsRequest<{ status: string }>(
`form/${createdFormId}`,
key,
{ method: 'DELETE' },
);
} catch {
// Best effort — the suite already failed if we got here.
}
}
});

it('forms.list returns a valid envelope with real forms', async () => {
const response = await makeByteFormsRequest<unknown>('form', key, {
method: 'GET',
});

const parsed = ByteFormsEndpointOutputSchemas.formsList.parse(response);
expect(parsed.status).toBe('success');
expect(Array.isArray(parsed.data)).toBe(true);
});

it('forms.create creates a form and the output schema validates', async () => {
const response = await makeByteFormsRequest<unknown>('form', key, {
method: 'POST',
body: {
name: uniqueName,
body: [
{
component: 'input',
type: 'email',
label: 'Email',
id: 'email',
required: true,
},
],
options: { thank_you_message: 'Thanks from corsair tests!' },
},
});

const parsed = ByteFormsEndpointOutputSchemas.formsCreate.parse(response);
expect(parsed.status).toBe('success');
expect(parsed.data).toBeDefined();

createdFormId = parsed.data?.id;

expect(typeof createdFormId).toBe('number');
expect(createdFormId).toBeGreaterThan(0);
});

it('forms.get fetches the created form by numeric id', async () => {
if (createdFormId === undefined) {
throw new Error('Create test did not produce a form id');
}

const response = await makeByteFormsRequest<unknown>(
`form/${createdFormId}`,
key,
{ method: 'GET' },
);

const parsed = ByteFormsEndpointOutputSchemas.formsGet.parse(response);
expect(parsed.data.id).toBe(createdFormId);
expect(parsed.data.name).toBe(uniqueName);
expect(parsed.status).toBe('success');
expect(Array.isArray(parsed.data.body)).toBe(true);
expect(parsed.data.body.length).toBeGreaterThan(0);
const firstField = parsed.data.body[0];
if (!firstField) {
throw new Error('Created form has no fields');
}
expect(firstField.component).toBe('input');
expect(firstField.type).toBe('email');
});

it('forms.responses returns a valid paginated envelope', async () => {
if (createdFormId === undefined) {
throw new Error('Create test did not produce a form id');
}

const response = await makeByteFormsRequest<unknown>(
`form/responses/${createdFormId}`,
key,
{
method: 'GET',
query: { limit: 10, order: 'desc' },
},
);

const parsed =
ByteFormsEndpointOutputSchemas.formsResponses.parse(response);
expect(parsed.status).toBe('success');
expect(typeof parsed.count).toBe('number');
expect(parsed.count).toBeGreaterThanOrEqual(0);
expect(parsed.cursor).toHaveProperty('after');
expect(parsed.cursor).toHaveProperty('before');
expect(Array.isArray(parsed.data)).toBe(true);
});

it('forms.get on a nonexistent id surfaces a provider error', async () => {
await expect(
makeByteFormsRequest<unknown>('form/999999999', key, {
method: 'GET',
}),
).rejects.toThrow();
});

it('an invalid API key is rejected by the provider', async () => {
await expect(
makeByteFormsRequest<unknown>('form', 'definitely-not-a-valid-key', {
method: 'GET',
}),
).rejects.toThrow();
});

it('forms.delete removes the created form and it is no longer fetchable', async () => {
if (createdFormId === undefined) {
throw new Error('Create test did not produce a form id');
}

const response = await makeByteFormsRequest<unknown>(
`form/${createdFormId}`,
key,
{ method: 'DELETE' },
);

const parsed = ByteFormsEndpointOutputSchemas.formsDelete.parse(response);
expect(parsed.status).toBe('success');

// Mark cleaned up before the negative check so afterAll does not retry.
const deletedId = createdFormId;
createdFormId = undefined;

// The deleted form should no longer be retrievable.
await expect(
makeByteFormsRequest<unknown>(`form/${deletedId}`, key, {
method: 'GET',
}),
).rejects.toThrow();
});
});
163 changes: 163 additions & 0 deletions packages/byteforms/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http';
import { ApiError, request } from 'corsair/http';
import {
BYTEFORMS_API_BASE,
ByteFormsAPIError,
makeByteFormsRequest,
} from './client';

jest.mock('corsair/http', () => {
const actual = jest.requireActual('corsair/http');
return { ...actual, request: jest.fn() };
});

const mockRequest = request as jest.MockedFunction<typeof request>;

function lastCall(): [OpenAPIConfig, ApiRequestOptions] {
const call = mockRequest.mock.calls.at(-1);
if (!call) throw new Error('request() was never called');
return call as unknown as [OpenAPIConfig, ApiRequestOptions];
}

function apiError(status: number, retryAfter?: number): ApiError {
return new ApiError(
{ method: 'GET', url: 'form' },
{
url: `${BYTEFORMS_API_BASE}/form`,
ok: false,
status,
statusText: 'Error',
body: { message: 'failed', status: 'fail' },
},
status === 429 ? 'Too Many Requests' : 'Unauthorized',
retryAfter !== undefined ? { retryAfter } : undefined,
);
}

beforeEach(() => {
mockRequest.mockReset();
});

describe('makeByteFormsRequest', () => {
it('sends the raw API key in the Authorization header with no Bearer prefix', async () => {
mockRequest.mockResolvedValue({ data: {} });

await makeByteFormsRequest('form', 'secret-key');

const [config] = lastCall();
expect(config.BASE).toBe(BYTEFORMS_API_BASE);
expect(config.HEADERS).toMatchObject({
Authorization: 'secret-key',
'Content-Type': 'application/json',
});
expect(config.TOKEN).toBeUndefined();
});

it('issues a GET with the endpoint path and passes query parameters', async () => {
mockRequest.mockResolvedValue({ data: [] });

await makeByteFormsRequest('form/responses/9', 'k', {
method: 'GET',
query: { limit: 10, order: 'desc' },
});

const [, options] = lastCall();
expect(options.method).toBe('GET');
expect(options.url).toBe('form/responses/9');
expect(options.query).toEqual({ limit: 10, order: 'desc' });
});

it('returns the parsed body on success', async () => {
mockRequest.mockResolvedValue({ data: [], status: 'success' });

const result = await makeByteFormsRequest<{ data: unknown[] }>('form', 'k');

expect(result).toEqual({ data: [], status: 'success' });
});

it('sends a JSON body on write methods and omits query', async () => {
mockRequest.mockResolvedValue({ status: 'success' });

await makeByteFormsRequest('form', 'k', {
method: 'POST',
body: { name: 'Demo', options: { theme: 'light' } },
});

const [, options] = lastCall();
expect(options.method).toBe('POST');
expect(options.body).toEqual({
name: 'Demo',
options: { theme: 'light' },
});
expect(options.query).toBeUndefined();
expect(options.mediaType).toContain('application/json');
});

it('does not send a body on GET or DELETE', async () => {
mockRequest.mockResolvedValue({ status: 'success' });

await makeByteFormsRequest('form/1', 'k', { method: 'DELETE' });

const [, options] = lastCall();
expect(options.method).toBe('DELETE');
expect(options.body).toBeUndefined();
});

it('passes the rate-limit configuration to the http client', async () => {
mockRequest.mockResolvedValue({ data: {} });

await makeByteFormsRequest('form', 'k');

const [, options] = lastCall();
expect(options).not.toHaveProperty('rateLimitConfig');
const requestOptions = mockRequest.mock.calls[0]?.[2] as
| { rateLimitConfig?: { enabled: boolean; maxRetries: number } }
| undefined;
expect(requestOptions?.rateLimitConfig).toMatchObject({
enabled: true,
maxRetries: 0,
});
});

it('wraps an ApiError in ByteFormsAPIError, preserving status, retryAfter and cause', async () => {
const original = apiError(429, 1500);
mockRequest.mockRejectedValue(original);

try {
await makeByteFormsRequest('form', 'k');
throw new Error('expected makeByteFormsRequest to throw');
} catch (error) {
const wrapped = error as ByteFormsAPIError;
expect(wrapped).toBeInstanceOf(ByteFormsAPIError);
expect(wrapped.status).toBe(429);
expect(wrapped.code).toBe('429');
expect(wrapped.retryAfter).toBe(1500);
expect(wrapped.cause).toBe(original);
}
expect(mockRequest).toHaveBeenCalledTimes(1);
});

it('wraps a non-ApiError failure without inventing a status', async () => {
mockRequest.mockRejectedValue(new Error('socket hang up'));

try {
await makeByteFormsRequest('form', 'k');
throw new Error('expected makeByteFormsRequest to throw');
} catch (error) {
const wrapped = error as ByteFormsAPIError;
expect(wrapped).toBeInstanceOf(ByteFormsAPIError);
expect(wrapped.message).toBe('socket hang up');
expect(wrapped.status).toBeUndefined();
expect(wrapped.retryAfter).toBeUndefined();
}
});

it('wraps a thrown non-Error value as an unknown error', async () => {
mockRequest.mockRejectedValue('not an error');

await expect(makeByteFormsRequest('form', 'k')).rejects.toMatchObject({
name: 'ByteFormsAPIError',
message: 'Unknown error',
});
});
});
Loading
Loading