AcadeDoc/apps/extension-clipper/tests/api-client.test.ts
Corentin 23a85267bf feat(acadenice): add sync blocks for cross-page content sharing — R4.2
Implements Notion-style sync blocks: a Tiptap node whose content is shared
across N pages. Editing via the Hocuspocus overlay propagates to all instances
via Yjs collab + SSE broadcast (EventEmitter2 bus).

Server: DB migration, NestJS module (CRUD + BFS cycle detection + broadcast),
Hocuspocus persistence extension extended for sync-block-* docs, 3 new RBAC
permissions (sync_blocks:create/edit/delete), seeded to Admin/Editor/Member.

Client: SyncBlockExtension (Tiptap node), SyncBlockNodeView (NodeView +
Mantine Modal overlay + SSE hook), /sync-block slash command, service client.

Tests: 32 server Jest + 18 client Vitest, all green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:40:12 +02:00

110 lines
3.7 KiB
TypeScript

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');
});
});