Wiki/bridge/tests/integration/baserow-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

289 lines
9.4 KiB
TypeScript

/**
* Tests integration BaserowClient contre un faux serveur HTTP local.
*
* Choix : pas de container Baserow reel (boot 60-120s incompatible CI rapide).
* On utilise un serveur node:http qui simule les endpoints Baserow — ofetch
* tape un vrai socket TCP, donc on couvre headers, retries, mapping erreurs,
* pagination et methodes CRUD. Test "boundary integration" rigoureux.
*/
import pino from 'pino';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { BaserowClient } from '../../src/adapters/baserow-client.js';
import { BridgeError } from '../../src/lib/errors.js';
import { type FakeHttpServer, jsonResponse, startFakeHttpServer } from '../helpers/http-server.js';
const silentLogger = () => pino({ level: 'silent' });
describe('BaserowClient integration', () => {
let server: FakeHttpServer;
let client: BaserowClient;
const TOKEN = 'fake-baserow-token';
beforeAll(async () => {
server = await startFakeHttpServer();
});
afterAll(async () => {
await server.stop();
});
beforeEach(() => {
server.reset();
client = new BaserowClient({
baseUrl: server.url,
token: TOKEN,
logger: silentLogger(),
});
});
describe('listRows', () => {
it('passe le header Authorization Token et les query params par defaut', async () => {
server.setRoute('GET', /^\/api\/database\/rows\/table\/42\//, (_req, res) => {
jsonResponse(res, 200, { count: 0, next: null, previous: null, results: [] });
});
await client.listRows(42);
expect(server.requests).toHaveLength(1);
const req = server.requests[0];
expect(req?.headers.authorization).toBe(`Token ${TOKEN}`);
expect(req?.path).toContain('user_field_names=true');
expect(req?.path).toContain('size=100');
expect(req?.path).toContain('page=1');
});
it('renvoie la reponse paginee parsee', async () => {
const payload = {
count: 2,
next: null,
previous: null,
results: [
{ id: 1, order: '1', personne_nom: 'Dupont' },
{ id: 2, order: '2', personne_nom: 'Martin' },
],
};
server.setRoute('GET', /^\/api\/database\/rows\/table\/1\//, (_req, res) => {
jsonResponse(res, 200, payload);
});
const result = await client.listRows(1);
expect(result.count).toBe(2);
expect(result.results).toHaveLength(2);
expect(result.results[0]?.personne_nom).toBe('Dupont');
});
it('encode search, orderBy et filter dans la query', async () => {
server.setRoute('GET', /^\/api\/database\/rows\/table\/7\//, (_req, res) => {
jsonResponse(res, 200, { count: 0, next: null, previous: null, results: [] });
});
await client.listRows(7, {
page: 2,
size: 50,
search: 'pierre',
orderBy: '-id',
filter: { statut: 'actif', role: 'formateur' },
});
const path = server.requests[0]?.path ?? '';
expect(path).toContain('page=2');
expect(path).toContain('size=50');
expect(path).toContain('search=pierre');
expect(path).toContain('order_by=-id');
expect(path).toContain('filter__statut__contains=actif');
expect(path).toContain('filter__role__contains=formateur');
});
it('respecte userFieldNames=false', async () => {
server.setRoute('GET', /^\/api\/database\/rows\/table\/1\//, (_req, res) => {
jsonResponse(res, 200, { count: 0, next: null, previous: null, results: [] });
});
await client.listRows(1, { userFieldNames: false });
expect(server.requests[0]?.path).toContain('user_field_names=false');
});
it('mappe 401 → BridgeError AUTH_INVALID', async () => {
server.setRoute('GET', /^\/api\/database\/rows\/table\/1\//, (_req, res) => {
jsonResponse(res, 401, { detail: 'Invalid token' });
});
await expect(client.listRows(1)).rejects.toMatchObject({
code: 'AUTH_INVALID',
status: 401,
});
});
it('mappe 404 → BridgeError NOT_FOUND', async () => {
server.setRoute('GET', /^\/api\/database\/rows\/table\/999\//, (_req, res) => {
jsonResponse(res, 404, { detail: 'Not found' });
});
await expect(client.listRows(999)).rejects.toMatchObject({
code: 'NOT_FOUND',
status: 404,
});
});
});
describe('getRow', () => {
it('GET /api/database/rows/table/:tableId/:rowId/', async () => {
server.setRoute('GET', /^\/api\/database\/rows\/table\/4\/77\//, (_req, res) => {
jsonResponse(res, 200, { id: 77, order: '5', module_nom: 'JS' });
});
const row = await client.getRow(4, 77);
expect(row.id).toBe(77);
expect(row.module_nom).toBe('JS');
expect(server.requests[0]?.path).toContain('user_field_names=true');
});
it('passe user_field_names=false quand demande', async () => {
server.setRoute('GET', /^\/api\/database\/rows\/table\/4\/77\//, (_req, res) => {
jsonResponse(res, 200, { id: 77, order: '5' });
});
await client.getRow(4, 77, false);
expect(server.requests[0]?.path).toContain('user_field_names=false');
});
});
describe('createRow', () => {
it('POST avec body JSON et retourne la row creee', async () => {
server.setRoute('POST', /^\/api\/database\/rows\/table\/2\//, (_req, res, body) => {
const parsed = JSON.parse(body);
jsonResponse(res, 200, { id: 999, order: '1', ...parsed });
});
const created = await client.createRow(2, { nom: 'Test', heures: 10 });
expect(created.id).toBe(999);
expect(created.nom).toBe('Test');
const req = server.requests[0];
expect(req?.method).toBe('POST');
expect(JSON.parse(req?.body ?? '{}')).toEqual({ nom: 'Test', heures: 10 });
});
});
describe('updateRow', () => {
it('PATCH avec body JSON', async () => {
server.setRoute('PATCH', /^\/api\/database\/rows\/table\/5\/12\//, (_req, res, body) => {
const parsed = JSON.parse(body);
jsonResponse(res, 200, { id: 12, order: '1', ...parsed });
});
const updated = await client.updateRow(5, 12, { heures_realisees: 7 });
expect(updated.heures_realisees).toBe(7);
expect(server.requests[0]?.method).toBe('PATCH');
});
});
describe('deleteRow', () => {
it('DELETE puis no return', async () => {
server.setRoute('DELETE', /^\/api\/database\/rows\/table\/5\/12\//, (_req, res) => {
res.statusCode = 204;
res.end();
});
await expect(client.deleteRow(5, 12)).resolves.toBeUndefined();
expect(server.requests[0]?.method).toBe('DELETE');
});
});
describe('resolveTableIds', () => {
it('retourne le mapping name → id', async () => {
server.setRoute('GET', '/api/database/tables/database/133/', (_req, res) => {
jsonResponse(res, 200, [
{ id: 609, name: 'personne' },
{ id: 610, name: 'formation' },
{ id: 611, name: 'module' },
]);
});
const map = await client.resolveTableIds(133);
expect(map).toEqual({ personne: 609, formation: 610, module: 611 });
});
it('mappe 401 sur cet endpoint aussi', async () => {
server.setRoute('GET', '/api/database/tables/database/133/', (_req, res) => {
jsonResponse(res, 401, { detail: 'JWT required' });
});
await expect(client.resolveTableIds(133)).rejects.toBeInstanceOf(BridgeError);
});
});
describe('healthCheck', () => {
it('retourne true si /api/_health/ repond 200', async () => {
server.setRoute('GET', '/api/_health/', (_req, res) => {
jsonResponse(res, 200, { status: 'ok' });
});
expect(await client.healthCheck()).toBe(true);
});
it('retourne false si /api/_health/ erreur', async () => {
server.setRoute('GET', '/api/_health/', (_req, res) => {
res.statusCode = 500;
res.end('boom');
});
expect(await client.healthCheck()).toBe(false);
});
it('retourne false si serveur injoignable', async () => {
const dead = new BaserowClient({
baseUrl: 'http://127.0.0.1:1',
token: TOKEN,
logger: silentLogger(),
});
expect(await dead.healthCheck()).toBe(false);
});
});
describe('baseUrl trailing slash', () => {
it('strip le / final pour eviter les // dans les paths', async () => {
server.setRoute('GET', /^\/api\/database\/rows\/table\/1\//, (_req, res) => {
jsonResponse(res, 200, { count: 0, next: null, previous: null, results: [] });
});
const c = new BaserowClient({
baseUrl: `${server.url}/`,
token: TOKEN,
logger: silentLogger(),
});
await c.listRows(1);
expect(server.requests[0]?.path).not.toContain('//api');
});
});
describe('reseau down → BASEROW_UNAVAILABLE', () => {
let downClient: BaserowClient;
beforeAll(() => {
// 127.0.0.1:1 = port reserve, refus de connexion immediat → ofetch sans response → baserowDown
downClient = new BaserowClient({
baseUrl: 'http://127.0.0.1:1',
token: TOKEN,
logger: silentLogger(),
});
});
it('mappe une erreur reseau (pas de response) vers BASEROW_UNAVAILABLE', async () => {
await expect(downClient.listRows(1)).rejects.toMatchObject({
code: 'BASEROW_UNAVAILABLE',
status: 502,
});
}, 15_000);
});
// afterEach pas requis ici — server.reset() en beforeEach suffit
afterEach(() => {});
});