- 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.
487 lines
15 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|