/** * 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(() => {}); });