Files
heygo/tests/api.client.test.ts
T
2026-06-20 22:08:52 +10:00

257 lines
8.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest';
import {
ApiClientError,
buildPreviewUrl,
createPrivateLink,
deletePrivateLink,
deletePublicLink,
getPublicLink,
getPublicLinkHistory,
getPublicLinkStats,
listPrivateLinks,
listPublicLinks,
updatePrivateLink,
} from '../src/lib/api';
const originalFetch = globalThis.fetch;
function mockFetch(responder: (input: RequestInfo | URL, init?: RequestInit) => {
status?: number;
body?: unknown;
statusText?: string;
}) {
const calls: { path: string; init?: RequestInit }[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = typeof input === 'string' ? input : new URL(input.toString()).pathname;
calls.push({ path, init });
const { status = 200, body = {}, statusText = 'OK' } = responder(input, init);
return new Response(JSON.stringify(body), {
status,
statusText,
headers: { 'content-type': 'application/json' },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
return {
calls,
fetchMock,
};
}
afterEach(() => {
globalThis.fetch = originalFetch;
});
/** Like mockFetch but returns a bodyless response (for 204 / empty 200). */
function mockFetchNoBody(responder: (input: RequestInfo | URL, init?: RequestInit) => {
status?: number;
statusText?: string;
}) {
const calls: { path: string; init?: RequestInit }[] = [];
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = typeof input === 'string' ? input : new URL(input.toString()).pathname;
calls.push({ path, init });
const { status = 204, statusText = 'No Content' } = responder(input, init);
return new Response(null, { status, statusText });
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
return { calls, fetchMock };
}
describe('api client', () => {
it('listPrivateLinks sends GET with credentials to /api/links/private', async () => {
const { calls } = mockFetch(() => ({
body: { links: [{ id: 'link_1', alias: 'docs', scope: 'private', linkType: 'redirect' }] },
}));
const result = await listPrivateLinks();
expect(calls).toHaveLength(1);
expect(calls[0].path).toBe('/api/links/private');
expect(calls[0].init?.method).toBeUndefined();
expect(calls[0].init?.credentials).toBe('include');
expect(result.links).toHaveLength(1);
expect(result.links[0].alias).toBe('docs');
});
it('createPrivateLink POSTs a normalized JSON body', async () => {
const { calls } = mockFetch((_input, init) => ({
status: 201,
body: { link: { id: 'new', alias: 'docs', scope: 'private', linkType: 'redirect' } },
}));
const result = await createPrivateLink({
alias: 'Docs',
linkType: 'redirect',
targetUrl: 'https://example.com',
description: ' spaced ',
});
expect(calls[0].init?.method).toBe('POST');
expect(calls[0].init?.credentials).toBe('include');
const body = JSON.parse(calls[0].init?.body as string);
expect(body).toEqual({
alias: 'Docs',
linkType: 'redirect',
targetUrl: 'https://example.com',
description: ' spaced ',
});
expect(result.link.id).toBe('new');
});
it('updatePrivateLink targets the link id with PATCH', async () => {
const { calls } = mockFetch(() => ({
body: { link: { id: 'link_1', alias: 'renamed', scope: 'private', linkType: 'custom' } },
}));
await updatePrivateLink('link_1', { alias: 'renamed', linkType: 'custom', contentMarkdown: '# Hi' });
expect(calls[0].path).toBe('/api/links/private/link_1');
expect(calls[0].init?.method).toBe('PATCH');
const body = JSON.parse(calls[0].init?.body as string);
expect(body.linkType).toBe('custom');
expect(body).not.toHaveProperty('targetUrl');
expect(body.contentMarkdown).toBe('# Hi');
});
it('deletePrivateLink issues a DELETE request', async () => {
const { calls } = mockFetch(() => ({ body: { ok: true } }));
const result = await deletePrivateLink('link_1');
expect(calls[0].path).toBe('/api/links/private/link_1');
expect(calls[0].init?.method).toBe('DELETE');
expect(result.ok).toBe(true);
});
it('deletePrivateLink normalizes a 204 No Content response to { ok: true }', async () => {
const { calls } = mockFetchNoBody(() => ({ status: 204 }));
const result = await deletePrivateLink('link_1');
expect(calls[0].path).toBe('/api/links/private/link_1');
expect(calls[0].init?.method).toBe('DELETE');
expect(result).toEqual({ ok: true });
});
it('deletePublicLink normalizes a 204 No Content response to { ok: true }', async () => {
const { calls } = mockFetchNoBody(() => ({ status: 204 }));
const result = await deletePublicLink('link_1');
expect(calls[0].path).toBe('/api/admin/public-links/link_1');
expect(calls[0].init?.method).toBe('DELETE');
expect(result).toEqual({ ok: true });
});
it('deletePrivateLink normalizes an empty 200 body to { ok: true }', async () => {
mockFetchNoBody(() => ({ status: 200 }));
const result = await deletePrivateLink('link_1');
expect(result).toEqual({ ok: true });
});
it('listPublicLinks reads /api/links/public', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPublicLinks();
expect(calls[0].path).toBe('/api/links/public');
});
it('listPublicLinks appends q query parameter when search is provided', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPublicLinks('opencode');
expect(calls[0].path).toBe('/api/links/public?q=opencode');
});
it('listPublicLinks encodes special characters in the search query', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPublicLinks('go links');
expect(calls[0].path).toBe('/api/links/public?q=go%20links');
});
it('listPublicLinks ignores blank/whitespace-only queries', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPublicLinks(' ');
expect(calls[0].path).toBe('/api/links/public');
});
it('listPrivateLinks appends q query parameter when search is provided', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPrivateLinks('docs');
expect(calls[0].path).toBe('/api/links/private?q=docs');
});
it('listPrivateLinks ignores blank/whitespace-only queries', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPrivateLinks(' ');
expect(calls[0].path).toBe('/api/links/private');
});
it('public detail endpoints use the readable /api/links/public routes', async () => {
const { calls } = mockFetch(() => ({ body: { link: {}, stats: [], history: [] } }));
await getPublicLink('pub_1');
await getPublicLinkStats('pub_1', '3m');
await getPublicLinkHistory('pub_1');
expect(calls[0].path).toBe('/api/links/public/pub_1');
expect(calls[1].path).toBe('/api/links/public/pub_1/stats?period=3m');
expect(calls[2].path).toBe('/api/links/public/pub_1/history');
});
it('encodes link ids containing special path segments', async () => {
const { calls } = mockFetch(() => ({ body: { ok: true } }));
await deletePrivateLink('link/with/slash');
expect(calls[0].path).toBe('/api/links/private/link%2Fwith%2Fslash');
});
it('throws ApiClientError with the server error message on non-ok responses', async () => {
mockFetch(() => ({ status: 409, body: { error: 'Alias already exists' } }));
await expect(createPrivateLink({ alias: 'dup', linkType: 'redirect', targetUrl: 'https://x' }))
.rejects.toMatchObject({ name: 'ApiClientError', status: 409, message: 'Alias already exists' });
});
it('falls back to status text when the body has no error field', async () => {
mockFetch(() => ({ status: 500, body: {}, statusText: 'Internal Server Error' }));
await expect(listPrivateLinks()).rejects.toMatchObject({
name: 'ApiClientError',
status: 500,
message: '500 Internal Server Error',
});
});
});
describe('buildPreviewUrl', () => {
it('returns null for empty target URLs', () => {
expect(buildPreviewUrl(null)).toBeNull();
expect(buildPreviewUrl('')).toBeNull();
});
it('substitutes known template placeholders with encoded values', () => {
const url = 'https://example.com/search?q={query}&lang={lang}';
expect(buildPreviewUrl(url, { query: 'go links', lang: 'en' }))
.toBe('https://example.com/search?q=go%20links&lang=en');
});
it('replaces leftover placeholders with a readable token', () => {
expect(buildPreviewUrl('https://example.com/{region}/view')).toBe('https://example.com/example/view');
});
});