Files
umami/packages/api-client/src/http.test.ts
T

223 lines
6.6 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import { UmamiApiError } from './errors';
import type { OperationDefinition } from './generated/operations';
import {
buildAuthHeaders,
buildPath,
buildUrl,
sendRequest,
splitInput,
stripApiPrefix,
} from './http';
const statsOperation: OperationDefinition = {
operationId: 'getWebsiteStats',
method: 'get',
path: '/api/websites/{websiteId}/stats',
pathParams: ['websiteId'],
queryParams: ['startAt', 'endAt', 'browser'],
hasBody: false,
};
const funnelOperation: OperationDefinition = {
operationId: 'createWebsiteFunnel',
method: 'post',
path: '/api/websites/{websiteId}/funnels',
pathParams: ['websiteId'],
queryParams: [],
hasBody: true,
};
describe('buildPath', () => {
test('encodes path parameters', () => {
expect(buildPath('/api/websites/{websiteId}/stats', { websiteId: 'a b/c' })).toBe(
'/api/websites/a%20b%2Fc/stats',
);
});
test('throws on a missing path parameter', () => {
expect(() => buildPath('/api/websites/{websiteId}', {})).toThrow(/websiteId/);
});
});
describe('buildUrl', () => {
test('strips the /api prefix and joins with the base URL', () => {
expect(stripApiPrefix('/api/websites')).toBe('/websites');
expect(buildUrl('https://api.umami.is/v1/', '/api/websites', {}).toString()).toBe(
'https://api.umami.is/v1/websites',
);
expect(buildUrl('https://example.com/api', '/api/websites', {}).toString()).toBe(
'https://example.com/api/websites',
);
});
test('serializes query values, arrays and dates and skips nullish values', () => {
const url = buildUrl('https://example.com/api', '/api/websites', {
page: 2,
search: 'a b',
skip: undefined,
nothing: null,
flag: true,
tags: ['x', 'y'],
when: new Date('2024-01-02T03:04:05.000Z'),
});
expect(url.searchParams.getAll('tags')).toEqual(['x', 'y']);
expect(url.searchParams.get('page')).toBe('2');
expect(url.searchParams.get('search')).toBe('a b');
expect(url.searchParams.get('flag')).toBe('true');
expect(url.searchParams.get('when')).toBe('2024-01-02T03:04:05.000Z');
expect(url.searchParams.has('skip')).toBe(false);
expect(url.searchParams.has('nothing')).toBe(false);
});
});
describe('buildAuthHeaders', () => {
test('uses bearer authentication for tokens', () => {
expect(buildAuthHeaders({ token: 'abc' })).toEqual({ authorization: 'Bearer abc' });
});
test('sends API keys as both header conventions when no token is present', () => {
expect(buildAuthHeaders({ apiKey: 'umami_123' })).toEqual({
'x-umami-api-key': 'umami_123',
authorization: 'Bearer umami_123',
});
});
test('prefers the explicit token as bearer when both are present', () => {
expect(buildAuthHeaders({ apiKey: 'k', token: 't' })).toEqual({
'x-umami-api-key': 'k',
authorization: 'Bearer t',
});
});
});
describe('splitInput', () => {
test('routes known keys to path/query and unknown keys to query for GET', () => {
expect(
splitInput(statsOperation, {
websiteId: 'w',
startAt: 1,
endAt: 2,
browser1: 'chrome',
pf_plan: 'pro',
}),
).toEqual({
path: { websiteId: 'w' },
query: { startAt: 1, endAt: 2, browser1: 'chrome', pf_plan: 'pro' },
body: undefined,
});
});
test('routes unknown keys to the body for operations with a request body', () => {
expect(
splitInput(funnelOperation, {
websiteId: 'w',
name: 'Signup',
parameters: { steps: [] },
}),
).toEqual({
path: { websiteId: 'w' },
query: {},
body: { websiteId: 'w', name: 'Signup', parameters: { steps: [] } },
});
});
});
function jsonResponse(body: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'content-type': 'application/json' },
...init,
});
}
describe('sendRequest', () => {
test('sends JSON bodies and merges headers', async () => {
let captured: { url: string; init?: RequestInit } | undefined;
const fetchImpl = async (url: string | URL, init?: RequestInit) => {
captured = { url: url.toString(), init };
return jsonResponse({ ok: true });
};
const result = await sendRequest(fetchImpl, {
method: 'post',
url: new URL('https://example.com/api/websites/w/funnels'),
headers: { authorization: 'Bearer t', 'x-custom': '1' },
body: { websiteId: 'w' },
});
expect(result).toEqual({ ok: true });
expect(captured?.init?.method).toBe('POST');
expect(captured?.init?.body).toBe(JSON.stringify({ websiteId: 'w' }));
expect(captured?.init?.headers).toMatchObject({
authorization: 'Bearer t',
'x-custom': '1',
'content-type': 'application/json',
accept: 'application/json',
});
});
test('throws UmamiApiError with the API error code for 4xx responses', async () => {
const fetchImpl = async () =>
jsonResponse(
{ error: { message: 'Nope', code: 'unauthorized', status: 401 } },
{ status: 401 },
);
await expect(
sendRequest(fetchImpl, {
method: 'get',
url: new URL('https://example.com/api/websites'),
headers: {},
}),
).rejects.toMatchObject({
name: 'UmamiApiError',
status: 401,
code: 'unauthorized',
message: 'Nope',
isUnauthorized: true,
});
});
test('throws UmamiApiError for 5xx responses without a JSON body', async () => {
const fetchImpl = async () => new Response('boom', { status: 502 });
const error = (await sendRequest(fetchImpl, {
method: 'get',
url: new URL('https://example.com/api/websites'),
headers: {},
}).catch(e => e)) as UmamiApiError;
expect(error).toBeInstanceOf(UmamiApiError);
expect(error.status).toBe(502);
expect(error.code).toBe('server-error');
expect(error.isServerError).toBe(true);
});
test('throws on malformed JSON in successful responses', async () => {
const fetchImpl = async () =>
new Response('{not json', { status: 200, headers: { 'content-type': 'application/json' } });
await expect(
sendRequest(fetchImpl, {
method: 'get',
url: new URL('https://example.com/api/websites'),
headers: {},
}),
).rejects.toMatchObject({ code: 'invalid-json' });
});
test('returns undefined for empty bodies', async () => {
const fetchImpl = async () => new Response(null, { status: 204 });
await expect(
sendRequest(fetchImpl, {
method: 'delete',
url: new URL('https://example.com/api/websites/w'),
headers: {},
}),
).resolves.toBeUndefined();
});
});