Compare commits

...

3 commits

Author SHA1 Message Date
c4f087b697 docs(session): Bloc 6 done + fix ZSET
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
2026-05-07 20:38:07 +02:00
1cdb1b6ca4 fix(redis-cache): membre ZSET unique pour eviter collision sub-ms dans checkRateLimit
Date.now() seul collisionne sur appels concurrents dans la meme milliseconde,
ce qui faisait compter 1 entry au lieu de N dans la fenetre glissante.
Suffixe randomUUID pour garantir l'unicite du membre. Ajoute test burst 10x
qui prouve les 5 allowed + 5 denied attendus.
2026-05-07 20:38:07 +02:00
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
8 changed files with 1154 additions and 23 deletions

View file

@ -1,11 +1,11 @@
# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 soir)
# SESSION RESUME — formation-hub Acadenice (last update 2026-05-07 nuit)
> Document de reference pour reprendre le travail apres restart Claude Code OU /compact.
> Lis-moi avant de commencer la prochaine session.
## CHANGELOG depuis derniere update (session 2026-05-07 soir)
## CHANGELOG depuis derniere update (session 2026-05-07 nuit — Bloc 6)
4 commits ajoutes (`5b2abbc`, `2c5665b`, `c8e9b4d`, `7a3fbe4`) — bridge passe de "scaffold + 4 agents recrutes" a "service utilisable end-to-end pour 10 endpoints Tier 1" :
5 commits ajoutes (`5b2abbc`, `2c5665b`, `c8e9b4d`, `7a3fbe4`, `1528017`) — bridge passe de "scaffold + 4 agents recrutes" a "service utilisable end-to-end + adapters couverts a 97-100%" :
- **Bloc 1 cloture** (`5b2abbc`) : adapters propres (TS errors fixed, biome format), 679 LOC.
- **Bloc 2 livre** (`2c5665b`) : domain models 12 fichiers (Personne, Module, Attribution, Tache, etc.) + 111 tests Vitest, coverage **97.86%** lines sur `src/domain/`. Decimal.js partout pour heures, schemas zod, RG-01 implementee dans Module.creerAttribution.
@ -13,6 +13,13 @@
- **Smoke test fixes** (`7a3fbe4`) : 2 bugs decouverts via test live contre Baserow + Docmost reels :
- `BaserowClient.resolveTableIds` requiert un JWT user (Baserow API distingue DB tokens / JWT). Workaround : env var `BASEROW_TABLE_IDS` JSON override.
- `BaseRepo.list` cassait sur row malformee (Personne avec splits null != 100 → throw). Fix : try/catch toDomain par row, skip + log warn + `meta.skipped` exposed.
- **Bloc 6 livre** (`1528017`) : tests integration des 3 adapters via bridge-tester. **59 nouveaux tests** (220/220 verts au total) :
- `redis-cache.test.ts` : 16 tests via testcontainers redis:7-alpine, **100% lines / 95.2% branches**.
- `baserow-client.test.ts` : 18 tests via faux serveur node:http local, **99% lines / 96.9% branches**.
- `docmost-client.test.ts` : 25 tests via faux serveur node:http (login + cookie + envelope `{data}`), **97.7% lines / 93.7% branches**.
- Choix technique : faux serveur HTTP plutot que container Baserow/Docmost (boot 60-120s incompatible CI rapide). Le code adapter tape un vrai socket TCP via ofetch/fetch — boundary integration rigoureux. Helper reutilisable `tests/helpers/http-server.ts`.
- vitest.config.ts : threshold 70% lines+branches ajoute sur `src/adapters/**`.
- Note design : `RedisCache.checkRateLimit` utilise `${Date.now()}` comme membre ZSET → collision si plusieurs appels dans la meme ms. Workaround dans tests (delay 2ms). Pas critique en prod (charge plus diffuse) mais a noter.
## Smoke test live — etat actuel
@ -41,26 +48,35 @@ Stack live + bridge testes :
| Bloc | Status | Detail |
|------|--------|--------|
| 1 — Adapters | DONE | `5b2abbc`, 0% test coverage (Bloc 6) |
| 1 — Adapters | DONE | `5b2abbc`, coverage adapters 97-100% via Bloc 6 |
| 2 — Domain models | DONE | `2c5665b`, 97.86% coverage |
| 3 — Routes Tier 1 + auth + repos | DONE | `c8e9b4d`, 10/10 endpoints, 86-96% coverage middleware/routes |
| 3.2 — Refactor erreurs domain typees + routes /blocs /clients /taches | TODO | DomainError sub-classes (RGViolationError, ConflictError) pour remplacer mapping par texte |
| 4 — Auth middleware | DONE (en partie) | inclus dans Bloc 3 (Bearer brg_*, scopes JSON-encoded, admin:* wildcard) |
| 5 — Rate limit + cache invalidation | TODO | RedisCache.checkRateLimit existe deja, faut middleware Hono qui l'appelle |
| 6 — Tests integration adapters | TODO | testcontainers Postgres + Redis ephemeres, 0% coverage adapters actuel |
| 6 — Tests integration adapters | DONE | `1528017`, 59 tests, redis-cache 100% / baserow 99% / docmost 97.7% lines |
| 7 — Webhook handlers Baserow + sync bidirec | TODO | gros bloc (~2-3h) — coeur du projet |
| 8 — Tiptap node-views Docmost | TODO | docmost-fork-dev, Phase 2.3+ |
| 9 — Bidirec backlinks | TODO | docmost-fork-dev, Phase 3 |
| 10 — Doc utilisateur + release v0.1.0 | TODO | tech-writer + acadenice-devops |
## Coverage globale (post-Bloc 6)
- **All files** : 85.7% lines / 85.06% branches
- **adapters/** : 98.73% lines / 95.04% branches (cible 70% largement depassee)
- **domain/** : 97.86% lines / 98.16% branches
- **routes/** : 96.29% lines / 68.83% branches (a couvrir 70% branches → Bloc 3.2)
- **middleware/** : 86.41% lines / 88.88% branches
- **lib/** : 49.72% lines (config.ts non couvert — c'est normal, bootstrap)
- **repos/** : 59.53% lines (BaseRepo abstract — couvert via repos concrets, sera ameliore Bloc 7)
## Vote pour la prochaine session
Le reste des blocs est dans Phase 2 — Bridge service du SESSION-RESUME plus bas. Recommandation pour la reprise :
Recommandation pour la reprise :
- **Option A (recommandee)** : Bloc 7 — webhooks Baserow + premier sync bidirec auto. C'est ce qui rend le bridge utile au-dela de "REST sur Baserow". Gros bloc 2-3h.
- **Option B** : Bloc 5 — rate limit + cache invalidation. Court (~1h), prerequis prod, prepare Bloc 7.
- **Option C** : Bloc 6 — tests integration adapters via testcontainers. Solidifie la fondation Bloc 1 qui est a 0% actuellement. ~1.5h via bridge-tester.
- **Option D** : Bloc 3.2 — refactor erreurs domain typees + routes restantes (/blocs, /clients, /taches). Pas urgent.
- **Option A (recommandee)** : Bloc 7 — webhooks Baserow + premier sync bidirec auto. C'est ce qui rend le bridge utile au-dela de "REST sur Baserow". Gros bloc 2-3h. Les adapters sont maintenant solides (Bloc 6 done) → bonne fondation pour les handlers webhook qui consommeront RedisCache.checkAndStoreEventId pour l'idempotence.
- **Option B** : Bloc 5 — rate limit + cache invalidation. Court (~1h), prerequis prod, prepare Bloc 7. Note : `checkRateLimit` a un bug latent (collision Date.now() ms) — a fixer en passant (utiliser `${now}-${randomUUID}` comme membre ZSET).
- **Option C** : Bloc 3.2 — refactor erreurs domain typees + routes restantes (/blocs, /clients, /taches). Pas urgent.
## Vision projet en 3 lignes

View file

@ -3,6 +3,7 @@
* Utilise pour : cache rows Baserow, idempotence webhooks, rate limiting.
*/
import { randomUUID } from 'node:crypto';
import IORedis from 'ioredis';
import type { Redis } from 'ioredis';
import type { Logger } from 'pino';
@ -94,7 +95,8 @@ export class RedisCache {
const now = Date.now();
const windowKey = `bridge:rate:${key}`;
const multi = this.client.multi();
multi.zadd(windowKey, now, `${now}`);
// member unique : timestamp seul collisionne sur appels sub-ms (1 entry au lieu de N)
multi.zadd(windowKey, now, `${now}-${randomUUID()}`);
multi.zremrangebyscore(windowKey, 0, now - windowSeconds * 1000);
multi.zcard(windowKey);
multi.expire(windowKey, windowSeconds);

View file

@ -0,0 +1,105 @@
/**
* Helper : serveur HTTP local Node natif (boundary integration).
*
* Pourquoi pas testcontainers Baserow / Docmost reels ? Demarrage 60-120s par
* suite, trop couteux pour CI. On simule les endpoints via http.createServer
* ofetch + fetch natif des adapters tapent un vrai socket TCP, donc on teste
* le pipeline reseau, le parsing, les retries, le timeout, les codes d erreur.
* Si on veut un jour un test container heavy, ajouter un flag INTEGRATION_HEAVY=1.
*/
import { type IncomingMessage, type Server, type ServerResponse, createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
export interface HttpRequestRecord {
method: string;
path: string;
headers: NodeJS.Dict<string | string[]>;
body: string;
}
export type RouteHandler = (
req: IncomingMessage,
res: ServerResponse,
body: string,
) => void | Promise<void>;
export interface FakeHttpServer {
url: string;
port: number;
requests: HttpRequestRecord[];
setRoute(method: string, pathPattern: string | RegExp, handler: RouteHandler): void;
reset(): void;
stop(): Promise<void>;
}
interface RouteEntry {
method: string;
matcher: string | RegExp;
handler: RouteHandler;
}
export async function startFakeHttpServer(): Promise<FakeHttpServer> {
const requests: HttpRequestRecord[] = [];
const routes: RouteEntry[] = [];
const server: Server = createServer((req, res) => {
let body = '';
req.on('data', (chunk) => {
body += chunk;
});
req.on('end', async () => {
const path = req.url ?? '/';
const method = (req.method ?? 'GET').toUpperCase();
requests.push({ method, path, headers: req.headers, body });
const route = routes.find((r) => {
if (r.method !== method) return false;
if (typeof r.matcher === 'string') return r.matcher === path.split('?')[0];
return r.matcher.test(path);
});
if (!route) {
res.statusCode = 404;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'no route registered', method, path }));
return;
}
try {
await route.handler(req, res, body);
} catch (err) {
res.statusCode = 500;
res.end(JSON.stringify({ error: (err as Error).message }));
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const addr = server.address() as AddressInfo;
const port = addr.port;
return {
url: `http://127.0.0.1:${port}`,
port,
requests,
setRoute(method, pathPattern, handler) {
routes.push({ method: method.toUpperCase(), matcher: pathPattern, handler });
},
reset() {
requests.length = 0;
routes.length = 0;
},
stop() {
return new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
});
},
};
}
export function jsonResponse(res: ServerResponse, status: number, body: unknown): void {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(body));
}

View file

@ -0,0 +1,289 @@
/**
* 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(() => {});
});

View file

@ -0,0 +1,487 @@
/**
* 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');
});
});
});

View file

@ -0,0 +1,237 @@
/**
* Tests integration RedisCache contre un vrai Redis 7 (testcontainers).
*
* Choix : testcontainers Redis 7-alpine est leger (~3-5s) et l'API ioredis
* est complexe a mocker fidelement (multi/scan/zadd...) donc on prefere un vrai
* container plutot qu'un mock. Coverage attendue >= 70% lines+branches.
*/
import IORedis from 'ioredis';
import pino from 'pino';
import { GenericContainer, type StartedTestContainer } from 'testcontainers';
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import { RedisCache } from '../../src/adapters/redis-cache.js';
const silentLogger = () => pino({ level: 'silent' });
describe('RedisCache integration', () => {
let container: StartedTestContainer;
let cache: RedisCache;
let url: string;
beforeAll(async () => {
container = await new GenericContainer('redis:7-alpine').withExposedPorts(6379).start();
const host = container.getHost();
const port = container.getMappedPort(6379);
url = `redis://${host}:${port}`;
cache = new RedisCache({ url, logger: silentLogger() });
// Attendre l'event 'ready' avant de lancer les tests — sinon le 1er test peut
// hit le client avant que la handshake soit terminee (enableOfflineQueue: false).
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('redis ready timeout')), 10_000);
// biome-ignore lint/suspicious/noExplicitAny: acces interne au client pour test
const client = (cache as unknown as { client: any }).client;
if (client.status === 'ready') {
clearTimeout(timer);
resolve();
} else {
client.once('ready', () => {
clearTimeout(timer);
resolve();
});
}
});
}, 60_000);
afterAll(async () => {
await cache?.close();
await container?.stop();
}, 30_000);
// Nettoyage entre tests pour eviter les pollutions
afterEach(async () => {
const flushClient = new IORedis(url);
await flushClient.flushdb();
await flushClient.quit();
});
describe('get/set roundtrip', () => {
it('set + get retourne la valeur JSON typee', async () => {
const key = `test:obj:${crypto.randomUUID()}`;
const value = { foo: 'bar', n: 42, list: [1, 2, 3] };
await cache.set(key, value, 60);
const got = await cache.get<typeof value>(key);
expect(got).toEqual(value);
});
it('get retourne null si la cle est absente', async () => {
const got = await cache.get('non-existent-key');
expect(got).toBeNull();
});
it('respecte le TTL par defaut (300s) quand non fourni', async () => {
const flushClient = new IORedis(url);
const key = `test:ttl:${crypto.randomUUID()}`;
await cache.set(key, { x: 1 });
const ttl = await flushClient.ttl(key);
await flushClient.quit();
// TTL doit etre proche de 300s (peut varier de quelques secondes)
expect(ttl).toBeGreaterThan(290);
expect(ttl).toBeLessThanOrEqual(300);
});
it('respecte un TTL custom', async () => {
const flushClient = new IORedis(url);
const key = `test:ttl-custom:${crypto.randomUUID()}`;
await cache.set(key, 'x', 42);
const ttl = await flushClient.ttl(key);
await flushClient.quit();
expect(ttl).toBeGreaterThan(35);
expect(ttl).toBeLessThanOrEqual(42);
});
it('get supprime la cle si la valeur stockee n est pas du JSON valide', async () => {
const flushClient = new IORedis(url);
const key = `test:bad-json:${crypto.randomUUID()}`;
await flushClient.set(key, 'pas-du-json{{{');
const got = await cache.get(key);
expect(got).toBeNull();
const after = await flushClient.get(key);
await flushClient.quit();
expect(after).toBeNull();
});
});
describe('del', () => {
it('supprime une cle unique', async () => {
const key = `test:del:${crypto.randomUUID()}`;
await cache.set(key, 'v', 60);
await cache.del(key);
expect(await cache.get(key)).toBeNull();
});
it('supprime un tableau de cles', async () => {
const k1 = `test:del:${crypto.randomUUID()}`;
const k2 = `test:del:${crypto.randomUUID()}`;
await cache.set(k1, 'a', 60);
await cache.set(k2, 'b', 60);
await cache.del([k1, k2]);
expect(await cache.get(k1)).toBeNull();
expect(await cache.get(k2)).toBeNull();
});
it('no-op sur tableau vide (pas d erreur)', async () => {
await expect(cache.del([])).resolves.toBeUndefined();
});
});
describe('invalidatePattern', () => {
it('supprime toutes les cles matchant le pattern et retourne le count', async () => {
const prefix = `test:invalidate:${crypto.randomUUID()}`;
await cache.set(`${prefix}:a`, 1, 60);
await cache.set(`${prefix}:b`, 2, 60);
await cache.set(`${prefix}:c`, 3, 60);
// Cle qui ne matche pas — doit survivre.
const survivor = `test:other:${crypto.randomUUID()}`;
await cache.set(survivor, 99, 60);
const count = await cache.invalidatePattern(`${prefix}:*`);
expect(count).toBe(3);
expect(await cache.get(`${prefix}:a`)).toBeNull();
expect(await cache.get(survivor)).toBe(99);
});
it('retourne 0 quand aucun match', async () => {
const count = await cache.invalidatePattern(`test:nomatch:${crypto.randomUUID()}:*`);
expect(count).toBe(0);
});
});
describe('checkAndStoreEventId (idempotence webhooks)', () => {
it('retourne false sur premier call, true sur duplicate', async () => {
const eventId = crypto.randomUUID();
const firstSeen = await cache.checkAndStoreEventId(eventId, 60);
const secondSeen = await cache.checkAndStoreEventId(eventId, 60);
expect(firstSeen).toBe(false);
expect(secondSeen).toBe(true);
});
it('TTL applique sur la cle event', async () => {
const flushClient = new IORedis(url);
const eventId = crypto.randomUUID();
await cache.checkAndStoreEventId(eventId, 30);
const ttl = await flushClient.ttl(`bridge:webhook:event:${eventId}`);
await flushClient.quit();
expect(ttl).toBeGreaterThan(20);
expect(ttl).toBeLessThanOrEqual(30);
});
});
describe('checkRateLimit', () => {
it('autorise les requetes sous la limite', async () => {
const key = `rl:${crypto.randomUUID()}`;
const r1 = await cache.checkRateLimit(key, 5, 10);
const r2 = await cache.checkRateLimit(key, 5, 10);
const r3 = await cache.checkRateLimit(key, 5, 10);
expect(r1).toBe(true);
expect(r2).toBe(true);
expect(r3).toBe(true);
});
it('refuse les requetes au-dela de la limite', async () => {
const key = `rl-deny:${crypto.randomUUID()}`;
for (let i = 0; i < 3; i++) {
await cache.checkRateLimit(key, 3, 10);
}
const overLimit = await cache.checkRateLimit(key, 3, 10);
expect(overLimit).toBe(false);
});
it('compte chaque appel meme en burst sub-ms (membre ZSET unique)', async () => {
const key = `rl-burst:${crypto.randomUUID()}`;
const results = await Promise.all(
Array.from({ length: 10 }, () => cache.checkRateLimit(key, 5, 10)),
);
const allowed = results.filter((r) => r === true).length;
const denied = results.filter((r) => r === false).length;
expect(allowed).toBe(5);
expect(denied).toBe(5);
});
});
describe('healthCheck', () => {
it('retourne true quand Redis repond', async () => {
const ok = await cache.healthCheck();
expect(ok).toBe(true);
});
it('retourne false quand le client echoue (URL invalide)', async () => {
const broken = new RedisCache({
url: 'redis://127.0.0.1:1', // port qui ne repond pas
logger: silentLogger(),
});
const ok = await broken.healthCheck();
expect(ok).toBe(false);
// Cleanup — on ne crash pas sur close meme si non connecte.
await broken.close().catch(() => {});
});
});
});

View file

@ -1,11 +0,0 @@
import { describe, expect, it } from 'vitest';
describe('integration sanity', () => {
it('environment is reachable', () => {
expect(process.env.NODE_ENV).toBeDefined();
});
it('placeholder for future Baserow + Redis integration tests', () => {
expect(true).toBe(true);
});
});

View file

@ -11,13 +11,19 @@ export default defineConfig({
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/index.ts'],
thresholds: {
// Threshold strict applique uniquement sur src/domain. Adapters et lib seront couverts au bloc 3+.
// Threshold strict applique sur src/domain. Adapters couverts via tests integration (bloc 6).
'src/domain/**': {
lines: 80,
functions: 80,
branches: 80,
statements: 80,
},
'src/adapters/**': {
lines: 70,
functions: 70,
branches: 70,
statements: 70,
},
},
},
passWithNoTests: true,