223 lines
6.6 KiB
TypeScript
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();
|
|
});
|
|
});
|