Files
twenty/packages/twenty-client-sdk/src/rest/__tests__/RestApiClient.test.ts
T
nitin 79f3a5243a Add callAppRoute to RestApiClient (#22863)
Adds a `callAppRoute` method to `RestApiClient` in
`twenty-client-sdk/rest`. It calls one of the app's own HTTP routes
using the injected `TWENTY_FUNCTIONS_URL`, resolved internally the same
way the client already resolves `TWENTY_API_URL`, so app code no longer
reads env vars or knows how function routes are hosted.

Both app runtimes already go through `RestApiClient` for route calls
(logic functions and front components), so both get this in one place;
front components keep the existing 401 token-refresh flow.

Pairs with #22825, which makes the injected `TWENTY_FUNCTIONS_URL`
callable in every topology (app custom domain -> workspace isolated
functions domain -> `SERVER_URL/s`).

Once this ships in an SDK release, Call Recorder's own-route plumbing
(logic-function and front-component utils) drops its URL resolution and
calls `client.callAppRoute(path, body)`.

---------

Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-07-15 18:07:16 +00:00

310 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { RestApiClient, RestApiClientError } from '../index';
const buildResponse = (
body: string,
init?: { status?: number; statusText?: string },
): Response =>
new Response(body, {
status: init?.status ?? 200,
statusText: init?.statusText ?? 'OK',
});
describe('RestApiClient', () => {
const originalProcess = (globalThis as Record<string, unknown>).process;
const originalCommunicationApi = (globalThis as Record<string, unknown>)
.frontComponentHostCommunicationApi;
beforeEach(() => {
(globalThis as Record<string, unknown>).process = {
env: {
TWENTY_API_URL: 'https://api.twenty.test',
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
},
};
});
afterEach(() => {
(globalThis as Record<string, unknown>).process = originalProcess;
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
originalCommunicationApi;
vi.restoreAllMocks();
});
it('should call the resolved url with a bearer token and parse the JSON response', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(buildResponse(JSON.stringify({ id: '42' })));
const client = new RestApiClient({ fetch: fetchMock });
const result = await client.get('/rest/companies');
expect(result).toEqual({ id: '42' });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, requestInit] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.twenty.test/rest/companies');
expect((requestInit.headers as Headers).get('Authorization')).toBe(
'Bearer app-access-token',
);
expect(requestInit.method).toBe('GET');
});
it('should serialize a JSON body and set the content type on post', async () => {
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
const client = new RestApiClient({ fetch: fetchMock });
await client.post('/rest/companies', { name: 'Twenty' });
const [, requestInit] = fetchMock.mock.calls[0];
expect(requestInit.method).toBe('POST');
expect((requestInit.headers as Headers).get('Content-Type')).toBe(
'application/json',
);
expect(requestInit.body).toBe(JSON.stringify({ name: 'Twenty' }));
});
it('should append query parameters while skipping nullish values', async () => {
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
const client = new RestApiClient({ fetch: fetchMock });
await client.get('/rest/companies', {
query: { limit: 10, search: undefined, includeArchived: false },
});
const [url] = fetchMock.mock.calls[0];
expect(url).toBe(
'https://api.twenty.test/rest/companies?limit=10&includeArchived=false',
);
});
it('should throw a RestApiClientError when the api url is missing', async () => {
(globalThis as Record<string, unknown>).process = { env: {} };
const fetchMock = vi.fn();
const client = new RestApiClient({ fetch: fetchMock });
await expect(client.get('/rest/companies')).rejects.toBeInstanceOf(
RestApiClientError,
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('should throw a RestApiClientError when the access token is missing', async () => {
(globalThis as Record<string, unknown>).process = {
env: { TWENTY_API_URL: 'https://api.twenty.test' },
};
const fetchMock = vi.fn();
const client = new RestApiClient({ fetch: fetchMock });
await expect(client.get('/rest/companies')).rejects.toBeInstanceOf(
RestApiClientError,
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('should surface non-2xx responses as a RestApiClientError carrying the parsed body', async () => {
const fetchMock = vi.fn().mockResolvedValue(
buildResponse(JSON.stringify({ message: 'Not found' }), {
status: 404,
statusText: 'Not Found',
}),
);
const client = new RestApiClient({ fetch: fetchMock });
await expect(client.get('/rest/companies')).rejects.toMatchObject({
status: 404,
body: { message: 'Not found' },
});
});
describe('app route paths', () => {
it('should strip the /s prefix and send app route paths to an isolated-domain functions url at the root', async () => {
(globalThis as Record<string, unknown>).process = {
env: {
TWENTY_API_URL: 'https://api.twenty.test',
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
},
};
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
const client = new RestApiClient({ fetch: fetchMock });
await client.post('/s/my-app/my-route', { remainingIds: ['a'] });
const [url, requestInit] = fetchMock.mock.calls[0];
expect(url).toBe('https://acme.functions.twenty.test/my-app/my-route');
expect(requestInit.method).toBe('POST');
});
it('should join a same-site functions url that already contains /s without doubling slashes', async () => {
(globalThis as Record<string, unknown>).process = {
env: {
TWENTY_API_URL: 'https://api.twenty.test',
TWENTY_FUNCTIONS_URL: 'https://api.twenty.test/s/',
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
},
};
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
const client = new RestApiClient({ fetch: fetchMock });
await client.post('/s/my-app/my-route');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
});
it('should fall back to the api url /s route when the functions url is not injected', async () => {
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
const client = new RestApiClient({ fetch: fetchMock });
await client.post('/s/my-app/my-route');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
});
it('should treat an empty functions url as not injected', async () => {
(globalThis as Record<string, unknown>).process = {
env: {
TWENTY_API_URL: 'https://api.twenty.test',
TWENTY_FUNCTIONS_URL: '',
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
},
};
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
const client = new RestApiClient({ fetch: fetchMock });
await client.post('/s/my-app/my-route');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.twenty.test/s/my-app/my-route');
});
it('should keep rest paths on the api url when a functions url is injected', async () => {
(globalThis as Record<string, unknown>).process = {
env: {
TWENTY_API_URL: 'https://api.twenty.test',
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
},
};
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
const client = new RestApiClient({ fetch: fetchMock });
await client.get('/rest/companies');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.twenty.test/rest/companies');
});
it('should keep unprefixed paths on the api url when a functions url is injected', async () => {
(globalThis as Record<string, unknown>).process = {
env: {
TWENTY_API_URL: 'https://api.twenty.test',
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
},
};
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
const client = new RestApiClient({ fetch: fetchMock });
await client.post('/my-app/my-route');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.twenty.test/my-app/my-route');
});
it('should prefer an explicit baseUrl over the functions url and keep the path untouched', async () => {
(globalThis as Record<string, unknown>).process = {
env: {
TWENTY_API_URL: 'https://api.twenty.test',
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
},
};
const fetchMock = vi.fn().mockResolvedValue(buildResponse('{}'));
const client = new RestApiClient({
baseUrl: 'https://explicit.twenty.test',
fetch: fetchMock,
});
await client.post('/s/my-app/my-route');
const [url] = fetchMock.mock.calls[0];
expect(url).toBe('https://explicit.twenty.test/s/my-app/my-route');
});
it('should resolve an app route url without sending a request', () => {
(globalThis as Record<string, unknown>).process = {
env: {
TWENTY_API_URL: 'https://api.twenty.test',
TWENTY_FUNCTIONS_URL: 'https://acme.functions.twenty.test',
TWENTY_APP_ACCESS_TOKEN: 'app-access-token',
},
};
const fetchMock = vi.fn();
const client = new RestApiClient({ fetch: fetchMock });
const url = client.resolveUrl('/s/documents/view', {
query: { id: 'record-1' },
});
expect(url).toBe(
'https://acme.functions.twenty.test/documents/view?id=record-1',
);
expect(fetchMock).not.toHaveBeenCalled();
});
it('should resolve a rest url on the api base without sending a request', () => {
const fetchMock = vi.fn();
const client = new RestApiClient({ fetch: fetchMock });
const url = client.resolveUrl('/rest/companies');
expect(url).toBe('https://api.twenty.test/rest/companies');
expect(fetchMock).not.toHaveBeenCalled();
});
});
it('should refresh the access token once on a 401 and retry the request', async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(buildResponse('', { status: 401 }))
.mockResolvedValueOnce(buildResponse(JSON.stringify({ ok: true })));
const requestAccessTokenRefresh = vi
.fn()
.mockResolvedValue('refreshed-token');
(globalThis as Record<string, unknown>).frontComponentHostCommunicationApi =
{ requestAccessTokenRefresh };
const client = new RestApiClient({ fetch: fetchMock });
const result = await client.get('/rest/companies');
expect(result).toEqual({ ok: true });
expect(requestAccessTokenRefresh).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(
(fetchMock.mock.calls[1][1].headers as Headers).get('Authorization'),
).toBe('Bearer refreshed-token');
});
});