/** * Tests integration DocmostClient contre un faux serveur HTTP local. * * Choix : meme rationale que baserow-client.test.ts — Docmost reel boote en * 90s+ et requiert Postgres + Redis, trop couteux pour CI rapide. On simule * /api/auth/login (cookie session), les endpoints POST envelope { data: ... } * et le flow re-auth sur 401. Coverage attendue >= 70% lines+branches. */ import pino from 'pino'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { DocmostClient } from '../../src/adapters/docmost-client.js'; import { type FakeHttpServer, jsonResponse, startFakeHttpServer } from '../helpers/http-server.js'; const silentLogger = () => pino({ level: 'silent' }); const FAKE_COOKIE = 'authToken=fake-session-id-123'; function setupLoginRoute(server: FakeHttpServer, ok = true): void { server.setRoute('POST', '/api/auth/login', (_req, res, body) => { if (!ok) { res.statusCode = 401; res.end(JSON.stringify({ error: 'invalid creds' })); return; } const parsed = JSON.parse(body); if (!parsed.email || !parsed.password) { jsonResponse(res, 400, { error: 'missing fields' }); return; } res.setHeader('set-cookie', `${FAKE_COOKIE}; Path=/; HttpOnly`); jsonResponse(res, 200, { user: { id: 'u-1' } }); }); } describe('DocmostClient integration', () => { let server: FakeHttpServer; let client: DocmostClient; const EMAIL = 'admin@test.local'; const PASSWORD = 'password-123'; beforeAll(async () => { server = await startFakeHttpServer(); }); afterAll(async () => { await server.stop(); }); beforeEach(() => { server.reset(); client = new DocmostClient({ baseUrl: server.url, email: EMAIL, password: PASSWORD, logger: silentLogger(), }); }); describe('auth flow', () => { it('login auto au premier call + reuse du cookie sur les suivants', async () => { setupLoginRoute(server); server.setRoute('POST', '/api/workspace/info', (_req, res) => { jsonResponse(res, 200, { data: { id: 'w-1', name: 'Acme', defaultSpaceId: 's-1' } }); }); const w1 = await client.getWorkspaceInfo(); const w2 = await client.getWorkspaceInfo(); expect(w1.id).toBe('w-1'); expect(w2.id).toBe('w-1'); // 1 login + 2 workspace/info = 3 reqs expect(server.requests).toHaveLength(3); expect(server.requests[0]?.path).toBe('/api/auth/login'); // Verifie que le cookie est passe sur les requetes suivantes expect(server.requests[1]?.headers.cookie).toBe(FAKE_COOKIE); expect(server.requests[2]?.headers.cookie).toBe(FAKE_COOKIE); }); it('login en echec → AUTH_INVALID', async () => { setupLoginRoute(server, false); server.setRoute('POST', '/api/workspace/info', (_req, res) => { jsonResponse(res, 200, { data: {} }); }); await expect(client.getWorkspaceInfo()).rejects.toMatchObject({ code: 'AUTH_INVALID', status: 401, }); }); it('re-auth automatique sur 401 puis retry', async () => { let loginCount = 0; server.setRoute('POST', '/api/auth/login', (_req, res) => { loginCount += 1; res.setHeader('set-cookie', `${FAKE_COOKIE}-v${loginCount}; Path=/`); jsonResponse(res, 200, { user: { id: 'u-1' } }); }); let infoCount = 0; server.setRoute('POST', '/api/workspace/info', (_req, res) => { infoCount += 1; if (infoCount === 1) { // Premier call : 401 → trigger re-auth jsonResponse(res, 401, { error: 'session expired' }); return; } jsonResponse(res, 200, { data: { id: 'w-2', name: 'X', defaultSpaceId: 's-2' } }); }); const w = await client.getWorkspaceInfo(); expect(w.id).toBe('w-2'); expect(loginCount).toBe(2); // login initial + re-auth expect(infoCount).toBe(2); // appel echoue + retry }); }); describe('payload envelope', () => { it('unwrap automatique de { data: T }', async () => { setupLoginRoute(server); server.setRoute('POST', '/api/spaces/', (_req, res) => { jsonResponse(res, 200, { data: { items: [ { id: 's-1', name: 'Default', slug: 'default', visibility: 'open', workspaceId: 'w-1', }, ], }, }); }); const spaces = await client.listSpaces(); expect(spaces).toHaveLength(1); expect(spaces[0]?.id).toBe('s-1'); }); it('passe-through si pas de { data } dans la reponse', async () => { setupLoginRoute(server); server.setRoute('POST', '/api/workspace/info', (_req, res) => { // Reponse "plate" sans envelope jsonResponse(res, 200, { id: 'w-flat', name: 'Flat', defaultSpaceId: 's-x' }); }); const w = await client.getWorkspaceInfo(); expect(w.id).toBe('w-flat'); }); }); describe('Spaces CRUD', () => { beforeEach(() => setupLoginRoute(server)); it('listSpaces avec pagination defaut + retourne items vide si manquant', async () => { server.setRoute('POST', '/api/spaces/', (_req, res, body) => { const parsed = JSON.parse(body); expect(parsed).toEqual({ page: 1, limit: 100 }); jsonResponse(res, 200, { data: {} }); }); const result = await client.listSpaces(); expect(result).toEqual([]); }); it('createSpace propage payload + defaults', async () => { server.setRoute('POST', '/api/spaces/create', (_req, res, body) => { const parsed = JSON.parse(body); jsonResponse(res, 200, { data: { id: 'new-s', name: parsed.name, slug: parsed.slug, visibility: parsed.visibility, workspaceId: 'w-1', }, }); }); const s = await client.createSpace({ name: 'Promo 2026', slug: 'promo-2026' }); expect(s.id).toBe('new-s'); expect(s.visibility).toBe('open'); // default const reqBody = JSON.parse(server.requests[1]?.body ?? '{}'); expect(reqBody.description).toBe(''); // default expect(reqBody.visibility).toBe('open'); }); it('createSpace respecte visibility=private', async () => { server.setRoute('POST', '/api/spaces/create', (_req, res, body) => { const parsed = JSON.parse(body); jsonResponse(res, 200, { data: { id: 'p', name: parsed.name, slug: parsed.slug, visibility: parsed.visibility, workspaceId: 'w-1', }, }); }); const s = await client.createSpace({ name: 'Secret', slug: 'secret', description: 'classified', visibility: 'private', }); expect(s.visibility).toBe('private'); const reqBody = JSON.parse(server.requests[1]?.body ?? '{}'); expect(reqBody.description).toBe('classified'); }); it('addSpaceMember envoie spaceId, userEmails, role', async () => { server.setRoute('POST', '/api/spaces/members/add', (_req, res, body) => { const parsed = JSON.parse(body); expect(parsed).toEqual({ spaceId: 'sp-1', userEmails: ['a@x.fr', 'b@x.fr'], role: 'reader', }); jsonResponse(res, 200, { data: {} }); }); await client.addSpaceMember('sp-1', ['a@x.fr', 'b@x.fr'], 'reader'); // Pas d'assertion supplementaire — l'absence de throw + l'assert dans handler suffit }); it('addSpaceMember role par defaut writer', async () => { server.setRoute('POST', '/api/spaces/members/add', (_req, res, body) => { const parsed = JSON.parse(body); expect(parsed.role).toBe('writer'); jsonResponse(res, 200, { data: {} }); }); await client.addSpaceMember('sp-1', ['a@x.fr']); }); }); describe('Pages CRUD', () => { beforeEach(() => setupLoginRoute(server)); it('createPage avec content + format markdown par defaut', async () => { server.setRoute('POST', '/api/pages/create', (_req, res, body) => { const parsed = JSON.parse(body); jsonResponse(res, 200, { data: { id: 'p-1', title: parsed.title, spaceId: parsed.spaceId, parentPageId: parsed.parentPageId ?? null, position: 1, }, }); }); const page = await client.createPage({ spaceId: 's-1', title: 'My Page', content: '# Hello', }); expect(page.id).toBe('p-1'); const reqBody = JSON.parse(server.requests[1]?.body ?? '{}'); expect(reqBody.format).toBe('markdown'); expect(reqBody.content).toBe('# Hello'); }); it('createPage sans content omet le champ', async () => { server.setRoute('POST', '/api/pages/create', (_req, res, body) => { const parsed = JSON.parse(body); expect(parsed.content).toBeUndefined(); jsonResponse(res, 200, { data: { id: 'p-2', title: parsed.title, spaceId: parsed.spaceId, parentPageId: null, position: 0, }, }); }); await client.createPage({ spaceId: 's-1', title: 'Empty' }); }); it('createPage avec parentPageId et format html', async () => { server.setRoute('POST', '/api/pages/create', (_req, res, body) => { const parsed = JSON.parse(body); expect(parsed.parentPageId).toBe('parent-1'); expect(parsed.format).toBe('html'); jsonResponse(res, 200, { data: { id: 'p-3', title: parsed.title, spaceId: parsed.spaceId, parentPageId: parsed.parentPageId, position: 2, }, }); }); const page = await client.createPage({ spaceId: 's-1', title: 'Sub', parentPageId: 'parent-1', format: 'html', content: '
x
', }); expect(page.parentPageId).toBe('parent-1'); }); it('getPageInfo POST avec pageId', async () => { server.setRoute('POST', '/api/pages/info', (_req, res, body) => { const parsed = JSON.parse(body); jsonResponse(res, 200, { data: { id: parsed.pageId, title: 'Got', spaceId: 's-1', parentPageId: null, position: null, }, }); }); const p = await client.getPageInfo('p-99'); expect(p.id).toBe('p-99'); expect(p.title).toBe('Got'); }); it('updatePage propage le payload', async () => { server.setRoute('POST', '/api/pages/update', (_req, res, body) => { const parsed = JSON.parse(body); jsonResponse(res, 200, { data: { id: parsed.pageId, title: parsed.title, spaceId: 's-1', parentPageId: null, position: null, }, }); }); const p = await client.updatePage({ pageId: 'p-1', title: 'New Title' }); expect(p.title).toBe('New Title'); }); it('deletePage POST sans return', async () => { server.setRoute('POST', '/api/pages/delete', (_req, res, body) => { const parsed = JSON.parse(body); expect(parsed.pageId).toBe('p-zap'); jsonResponse(res, 200, { data: {} }); }); await expect(client.deletePage('p-zap')).resolves.toBeUndefined(); }); }); describe('Shares', () => { beforeEach(() => setupLoginRoute(server)); it('createShare avec defaults', async () => { server.setRoute('POST', '/api/shares/create', (_req, res, body) => { const parsed = JSON.parse(body); expect(parsed.includeSubPages).toBe(false); jsonResponse(res, 200, { data: { id: 'sh-1', pageId: parsed.pageId } }); }); const sh = await client.createShare({ pageId: 'p-1' }); expect(sh.id).toBe('sh-1'); }); it('createShare avec password + expiresAt', async () => { server.setRoute('POST', '/api/shares/create', (_req, res, body) => { const parsed = JSON.parse(body); expect(parsed.password).toBe('s3cret'); expect(parsed.expiresAt).toBe('2026-12-31T00:00:00Z'); expect(parsed.includeSubPages).toBe(true); jsonResponse(res, 200, { data: { id: 'sh-2', pageId: parsed.pageId, expiresAt: parsed.expiresAt }, }); }); const sh = await client.createShare({ pageId: 'p-9', password: 's3cret', expiresAt: '2026-12-31T00:00:00Z', includeSubPages: true, }); expect(sh.id).toBe('sh-2'); }); it('deleteShare POST', async () => { server.setRoute('POST', '/api/shares/delete', (_req, res, body) => { const parsed = JSON.parse(body); expect(parsed.shareId).toBe('sh-zap'); jsonResponse(res, 200, { data: {} }); }); await expect(client.deleteShare('sh-zap')).resolves.toBeUndefined(); }); }); describe('error mapping', () => { beforeEach(() => setupLoginRoute(server)); it('404 → NOT_FOUND', async () => { server.setRoute('POST', '/api/pages/info', (_req, res) => { jsonResponse(res, 404, { error: 'page gone' }); }); await expect(client.getPageInfo('nope')).rejects.toMatchObject({ code: 'NOT_FOUND', status: 404, }); }); it('reseau down → DOCMOST_UNAVAILABLE', async () => { const dead = new DocmostClient({ baseUrl: 'http://127.0.0.1:1', email: EMAIL, password: PASSWORD, logger: silentLogger(), }); // Login va echouer en reseau → throw AUTH_INVALID via response check. // En vrai, fetch natif va throw, donc on s attend a un throw quel qu il soit. await expect(dead.getWorkspaceInfo()).rejects.toBeDefined(); }, 15_000); }); describe('healthCheck', () => { it('retourne true si /api/health renvoie status:ok', async () => { server.setRoute('GET', '/api/health', (_req, res) => { jsonResponse(res, 200, { status: 'ok' }); }); expect(await client.healthCheck()).toBe(true); }); it('retourne false si status != ok', async () => { server.setRoute('GET', '/api/health', (_req, res) => { jsonResponse(res, 200, { status: 'degraded' }); }); expect(await client.healthCheck()).toBe(false); }); it('retourne false si serveur down', async () => { const dead = new DocmostClient({ baseUrl: 'http://127.0.0.1:1', email: EMAIL, password: PASSWORD, logger: silentLogger(), }); expect(await dead.healthCheck()).toBe(false); }); }); describe('baseUrl trailing slash', () => { it('strip le / final', async () => { setupLoginRoute(server); server.setRoute('POST', '/api/workspace/info', (_req, res) => { jsonResponse(res, 200, { data: { id: 'w', name: 'x', defaultSpaceId: 's' } }); }); const c = new DocmostClient({ baseUrl: `${server.url}/`, email: EMAIL, password: PASSWORD, logger: silentLogger(), }); await c.getWorkspaceInfo(); // Pas de // dans le path login expect(server.requests[0]?.path).toBe('/api/auth/login'); }); }); });