Wiki/bridge/tests/integration/docmost-client.test.ts
Corentin JOGUET 1528017bab test(adapters): tests integration redis (testcontainers) + baserow/docmost (fake HTTP server)
- redis-cache.ts : 16 tests via testcontainers redis:7-alpine, coverage 100% lines / 95.2% branches
- baserow-client.ts : 18 tests via serveur node:http local, coverage 99% lines / 96.9% branches
- docmost-client.ts : 25 tests via serveur node:http local (login + cookie + envelope { data }), coverage 97.7% lines / 93.7% branches
- helper tests/helpers/http-server.ts : serveur Node natif reutilisable (request log + route registry)
- vitest.config.ts : ajout threshold 70% lines+branches sur src/adapters/**
- suppression sanity.test.ts (stub remplace par 3 vraies suites)
- justification fake HTTP vs container heavy en commentaire en tete de fichier

Resultat : 220/220 tests verts, coverage adapters >> seuil 70% requis.
2026-05-07 20:31:08 +02:00

487 lines
15 KiB
TypeScript

/**
* 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: '<p>x</p>',
});
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');
});
});
});