import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError, buildPreviewUrl, createPrivateLink, deletePrivateLink, deletePublicLink, 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', contentMarkdown: null, 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.targetUrl).toBeNull(); 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('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'); }); });