import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { sendClip, fetchSpaces, ClipPayload } from '../src/lib/api-client'; const BASE_URL = 'http://localhost:3000'; const TOKEN = 'clip_testtoken'; const samplePayload: ClipPayload = { url: 'https://example.com/article', title: 'My Article', target_workspace_id: 'ws-uuid', target_space_id: 'space-uuid', }; function mockFetch(status: number, body: unknown): void { global.fetch = vi.fn().mockResolvedValue({ ok: status >= 200 && status < 300, status, statusText: 'OK', json: async () => body, } as Response); } describe('sendClip', () => { afterEach(() => vi.restoreAllMocks()); it('returns ClipResult on 201 Created', async () => { const expected = { pageId: 'p-1', slugId: 'slug-1', url: samplePayload.url }; mockFetch(201, expected); const result = await sendClip(BASE_URL, TOKEN, samplePayload); expect(result.pageId).toBe('p-1'); expect(result.slugId).toBe('slug-1'); }); it('sends X-Clipper-Token header', async () => { mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' }); await sendClip(BASE_URL, TOKEN, samplePayload); const [_url, init] = (global.fetch as any).mock.calls[0]; expect((init as RequestInit).headers!['X-Clipper-Token']).toBe(TOKEN); }); it('posts to the correct endpoint', async () => { mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' }); await sendClip(BASE_URL, TOKEN, samplePayload); const [url] = (global.fetch as any).mock.calls[0]; expect(url).toBe(`${BASE_URL}/api/acadenice/clipper/import`); }); it('strips trailing slash from apiUrl', async () => { mockFetch(201, { pageId: 'p-1', slugId: 's-1', url: '' }); await sendClip(`${BASE_URL}/`, TOKEN, samplePayload); const [url] = (global.fetch as any).mock.calls[0]; expect(url).toBe(`${BASE_URL}/api/acadenice/clipper/import`); }); it('throws ApiError with statusCode on 401', async () => { mockFetch(401, { message: 'Unauthorized' }); await expect(sendClip(BASE_URL, TOKEN, samplePayload)).rejects.toMatchObject({ statusCode: 401, }); }); it('throws ApiError with statusCode on 400', async () => { mockFetch(400, { message: 'Bad request' }); await expect(sendClip(BASE_URL, TOKEN, samplePayload)).rejects.toMatchObject({ statusCode: 400, message: 'Bad request', }); }); it('throws Error with network message when fetch rejects', async () => { global.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')); await expect(sendClip(BASE_URL, TOKEN, samplePayload)).rejects.toThrow( /Network error/, ); }); }); describe('fetchSpaces', () => { afterEach(() => vi.restoreAllMocks()); it('returns empty array on network error', async () => { global.fetch = vi.fn().mockRejectedValue(new Error('Network down')); const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid'); expect(result).toEqual([]); }); it('returns empty array on non-ok response', async () => { mockFetch(404, {}); const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid'); expect(result).toEqual([]); }); it('maps space items correctly', async () => { const spaces = [ { id: 's-1', name: 'General', slug: 'general' }, { id: 's-2', name: 'Engineering', slug: 'engineering' }, ]; mockFetch(200, spaces); const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid'); expect(result).toHaveLength(2); expect(result[0].slug).toBe('general'); }); it('handles paginated response with items array', async () => { mockFetch(200, { items: [{ id: 's-1', name: 'General', slug: 'general' }], total: 1 }); const result = await fetchSpaces(BASE_URL, TOKEN, 'ws-uuid'); expect(result[0].id).toBe('s-1'); }); });