Wiki/bridge/tests/routes/tables.test.ts
Corentin JOGUET 2ed73fa948
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
feat(bridge): R1 refactor proxy generique style Notion
Pivot strategique : DocAdenice = produit Notion-like generique. Le bridge
est livre vide a un user qui cree ses tables Baserow comme il veut. Code
sans aucune ontologie metier.

Suppressions :
- 9 entites domain metier (Personne, Formation, Bloc, Module, Attribution,
  Client, Projet, Tache, Intervention) + types.ts (Role, statuts)
- baserow-repo.ts (mega-fichier 554 LOC avec 9 repos heritant BaseRepo)
- 6 routes metier (personnes, formations, projets, modules, interventions,
  attributions) + tests associes
- Lookup PersonneRepo.findByEmail dans middleware auth
- Mapping DEFAULT_ROLE_SCOPES dans middleware/scopes.ts
- Cascade rollup metier dans webhooks/baserow-handler.ts

Ajouts :
- Domain generique : Table, Row, Field, View + schemas zod refondus
- 4 repos generiques : tables / rows / fields / views
- Route unique routes/tables.ts avec 9 endpoints REST CRUD generiques
- Claim JWT acadenice_permissions[] lu directement dans le middleware auth
  (alimente par RBAC dynamique cote DocAdenice en R2)
- examples/acadenice-formation-hub/ : README + seed-baserow.md schema
  9 tables + example-roles.md (Formateur, Developpeur, Direction, Support,
  Admin avec permissions generiques)

Refactors :
- BaserowClient etendu : listTables, getTable, listFields, listViews,
  getGridViewRows
- middleware/auth.ts : extractPermissions(payload), AuthenticatedUser
  remplace roles[] par permissions[]
- middleware/scopes.ts : computeOidcScopes(groups, permissions, map)
- webhooks/baserow-handler.ts : invalidation generique
  bridge:tables:<tableId>:* sans cascade cross-table
- lib/cache.ts : invalidateEntity -> invalidateTable(redis, tableId, rowId?)
- container.ts : drop tableIds, RepoSet={tables, rows, fields, views}
- 501 NOT_IMPLEMENTED si DB token sur endpoints /tables qui exigent JWT

Tests : 250/250 verts (depuis 319). Coverage : domain 98.9%, adapters 89%,
auth 97.08%, rate-limit 100%, cache 100%, webhooks 100%.

Quality gates verts : typecheck, lint biome, vitest, coverage thresholds.

Refs: R1 dans le pivot strategique DocAdenice Notion-like generique.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:12:32 +02:00

525 lines
17 KiB
TypeScript

/**
* Tests integration des routes /api/v1/tables/* — proxy generique R1.
* Repos faked en memoire — pas d'appel reseau.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { Field } from '../../src/domain/field.js';
import { Row } from '../../src/domain/row.js';
import { Table } from '../../src/domain/table.js';
import { View } from '../../src/domain/view.js';
import type { RepoSet } from '../../src/lib/container.js';
import { errors } from '../../src/lib/errors.js';
import {
ADMIN_TOKEN,
READ_TOKEN,
WRITE_TOKEN,
buildTestApp,
installTestContainer,
resetTestContainer,
} from '../helpers/test-app.js';
// ---------------------------------------------------------------------------
// Fake repos
// ---------------------------------------------------------------------------
class FakeTablesRepo {
public listCalls: number[] = [];
public failOnList = false;
public failOnGet = false;
constructor(
public tablesByDb: Map<number, Table[]> = new Map(),
public tablesById: Map<number, Table> = new Map(),
) {}
async list(databaseId: number): Promise<Table[]> {
this.listCalls.push(databaseId);
if (this.failOnList) {
throw errors.authInvalid();
}
return this.tablesByDb.get(databaseId) ?? [];
}
async get(tableId: number): Promise<Table> {
if (this.failOnGet) {
throw errors.authInvalid();
}
const t = this.tablesById.get(tableId);
if (!t) throw errors.notFound('Table', tableId);
return t;
}
}
class FakeFieldsRepo {
constructor(public fieldsByTable: Map<number, Field[]> = new Map()) {}
async list(tableId: number): Promise<Field[]> {
return this.fieldsByTable.get(tableId) ?? [];
}
}
class FakeViewsRepo {
constructor(
public viewsByTable: Map<number, View[]> = new Map(),
public rowsByView: Map<number, Row[]> = new Map(),
) {}
async list(tableId: number): Promise<View[]> {
return this.viewsByTable.get(tableId) ?? [];
}
async runGrid(viewId: number, _tableId: number) {
const items = this.rowsByView.get(viewId) ?? [];
return {
items,
meta: { page: 1, per_page: 50, total: items.length, total_pages: 1 },
};
}
}
class FakeRowsRepo {
public lastCreate?: { tableId: number; fields: Record<string, unknown> };
public lastUpdate?: { tableId: number; rowId: number; fields: Record<string, unknown> };
public lastDelete?: { tableId: number; rowId: number };
public nextId = 1000;
constructor(public rowsByTable: Map<number, Row[]> = new Map()) {}
async list(tableId: number) {
const items = this.rowsByTable.get(tableId) ?? [];
return {
items,
meta: { page: 1, per_page: 50, total: items.length, total_pages: 1 },
};
}
async get(tableId: number, rowId: number): Promise<Row> {
const items = this.rowsByTable.get(tableId) ?? [];
const found = items.find((r) => r.id === rowId);
if (!found) throw errors.notFound('Row', rowId);
return found;
}
async create(tableId: number, fields: Record<string, unknown>): Promise<Row> {
this.lastCreate = { tableId, fields };
const id = this.nextId++;
return new Row({ id, tableId, fields });
}
async update(tableId: number, rowId: number, fields: Record<string, unknown>): Promise<Row> {
this.lastUpdate = { tableId, rowId, fields };
return new Row({ id: rowId, tableId, fields });
}
async delete(tableId: number, rowId: number): Promise<void> {
this.lastDelete = { tableId, rowId };
}
}
function buildFakeRepos(opts: {
tables?: FakeTablesRepo;
fields?: FakeFieldsRepo;
views?: FakeViewsRepo;
rows?: FakeRowsRepo;
}): RepoSet {
return {
tables: (opts.tables ?? new FakeTablesRepo()) as unknown as RepoSet['tables'],
fields: (opts.fields ?? new FakeFieldsRepo()) as unknown as RepoSet['fields'],
views: (opts.views ?? new FakeViewsRepo()) as unknown as RepoSet['views'],
rows: (opts.rows ?? new FakeRowsRepo()) as unknown as RepoSet['rows'],
};
}
function bootApp(opts: Parameters<typeof buildFakeRepos>[0] = {}) {
const repos = buildFakeRepos(opts);
const container = installTestContainer({ repos });
return { app: buildTestApp(container), repos };
}
afterEach(resetTestContainer);
// ---------------------------------------------------------------------------
// /tables (metadata)
// ---------------------------------------------------------------------------
describe('GET /api/v1/tables', () => {
it('401 sans token', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables?databaseId=5');
expect(res.status).toBe(401);
});
it('400 sans databaseId', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(400);
});
it('200 list tables d une database', async () => {
const tables = new FakeTablesRepo(
new Map([
[
5,
[
new Table({ id: 1, name: 'Personne', databaseId: 5 }),
new Table({ id: 2, name: 'Bloc', databaseId: 5 }),
],
],
]),
);
const { app } = bootApp({ tables });
const res = await app.request('/api/v1/tables?databaseId=5', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Array<{ id: number; name: string }> };
expect(body.data).toHaveLength(2);
expect(body.data[0]?.name).toBe('Personne');
});
it('501 si le upstream Baserow renvoie 401 (DB token au lieu de JWT)', async () => {
const tables = new FakeTablesRepo();
tables.failOnList = true;
const { app } = bootApp({ tables });
const res = await app.request('/api/v1/tables?databaseId=5', {
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
});
expect(res.status).toBe(501);
const body = (await res.json()) as { error: { details?: { reason?: string } } };
expect(body.error.details?.reason).toBe('jwt_required');
});
});
describe('GET /api/v1/tables/:tableId', () => {
it('200 avec fields embarques', async () => {
const tables = new FakeTablesRepo(
new Map(),
new Map([[42, new Table({ id: 42, name: 'X', databaseId: 5 })]]),
);
const fields = new FakeFieldsRepo(
new Map([[42, [new Field({ id: 100, name: 'nom', type: 'text', primary: true })]]]),
);
const { app } = bootApp({ tables, fields });
const res = await app.request('/api/v1/tables/42', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { id: number; name: string; fields: Array<{ name: string; primary: boolean }> };
};
expect(body.data.id).toBe(42);
expect(body.data.fields).toHaveLength(1);
expect(body.data.fields[0]?.primary).toBe(true);
});
it('400 si tableId non numerique', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables/abc', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(400);
});
it('404 si table inconnue', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables/9999', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(404);
});
});
// ---------------------------------------------------------------------------
// /tables/:tableId/fields
// ---------------------------------------------------------------------------
describe('GET /api/v1/tables/:tableId/fields', () => {
it('200 list fields', async () => {
const fields = new FakeFieldsRepo(
new Map([[1, [new Field({ id: 10, name: 'titre', type: 'text', primary: true })]]]),
);
const { app } = bootApp({ fields });
const res = await app.request('/api/v1/tables/1/fields', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Array<{ name: string }> };
expect(body.data[0]?.name).toBe('titre');
});
});
// ---------------------------------------------------------------------------
// /tables/:tableId/views
// ---------------------------------------------------------------------------
describe('GET /api/v1/tables/:tableId/views', () => {
it('200 list views', async () => {
const views = new FakeViewsRepo(
new Map([
[
1,
[
new View({ id: 100, name: 'Tous', type: 'grid', tableId: 1 }),
new View({ id: 101, name: 'Kanban', type: 'kanban', tableId: 1 }),
],
],
]),
);
const { app } = bootApp({ views });
const res = await app.request('/api/v1/tables/1/views', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Array<{ id: number; type: string }> };
expect(body.data).toHaveLength(2);
expect(body.data[1]?.type).toBe('kanban');
});
});
describe('GET /api/v1/tables/:tableId/views/:viewId/rows', () => {
it('200 rows filtrees par la view', async () => {
const views = new FakeViewsRepo(
new Map(),
new Map([
[
100,
[
new Row({ id: 1, tableId: 1, fields: { nom: 'Alice' } }),
new Row({ id: 2, tableId: 1, fields: { nom: 'Bob' } }),
],
],
]),
);
const { app } = bootApp({ views });
const res = await app.request('/api/v1/tables/1/views/100/rows', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ id: number; fields: Record<string, unknown> }>;
};
expect(body.data).toHaveLength(2);
expect(body.data[0]?.fields.nom).toBe('Alice');
});
});
// ---------------------------------------------------------------------------
// /tables/:tableId/rows CRUD
// ---------------------------------------------------------------------------
describe('GET /api/v1/tables/:tableId/rows', () => {
it('200 list paginee', async () => {
const rows = new FakeRowsRepo(
new Map([
[
5,
[
new Row({ id: 1, tableId: 5, fields: { nom: 'A' } }),
new Row({ id: 2, tableId: 5, fields: { nom: 'B' } }),
],
],
]),
);
const { app } = bootApp({ rows });
const res = await app.request('/api/v1/tables/5/rows', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: unknown[]; meta: { total: number } };
expect(body.data).toHaveLength(2);
expect(body.meta.total).toBe(2);
});
});
describe('GET /api/v1/tables/:tableId/rows/:rowId', () => {
it('200 avec fields opaques', async () => {
const rows = new FakeRowsRepo(
new Map([[5, [new Row({ id: 100, tableId: 5, fields: { nom: 'X', heures: 42 } })]]]),
);
const { app } = bootApp({ rows });
const res = await app.request('/api/v1/tables/5/rows/100', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { id: number; fields: Record<string, unknown> };
};
expect(body.data.id).toBe(100);
expect(body.data.fields.heures).toBe(42);
});
it('404 si row inconnue', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables/5/rows/9999', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(404);
});
});
describe('POST /api/v1/tables/:tableId/rows', () => {
it('201 cree une row + invalide cache', async () => {
const rows = new FakeRowsRepo();
const { app } = bootApp({ rows });
const res = await app.request('/api/v1/tables/5/rows', {
method: 'POST',
headers: {
Authorization: `Bearer ${WRITE_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ nom: 'Pierre', heures: 40 }),
});
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { id: number; fields: Record<string, unknown> } };
expect(body.data.fields.nom).toBe('Pierre');
expect(rows.lastCreate?.tableId).toBe(5);
expect(rows.lastCreate?.fields).toEqual({ nom: 'Pierre', heures: 40 });
});
it('403 sans scope write:tables', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables/5/rows', {
method: 'POST',
headers: {
Authorization: `Bearer ${READ_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ nom: 'X' }),
});
expect(res.status).toBe(403);
});
it('400 si body pas un objet', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables/5/rows', {
method: 'POST',
headers: {
Authorization: `Bearer ${WRITE_TOKEN}`,
'Content-Type': 'application/json',
},
body: '[1,2,3]',
});
expect(res.status).toBe(400);
});
});
describe('PATCH /api/v1/tables/:tableId/rows/:rowId', () => {
it('200 update + payload partiel', async () => {
const rows = new FakeRowsRepo();
const { app } = bootApp({ rows });
const res = await app.request('/api/v1/tables/5/rows/100', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${WRITE_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ heures: 45 }),
});
expect(res.status).toBe(200);
expect(rows.lastUpdate?.rowId).toBe(100);
expect(rows.lastUpdate?.fields).toEqual({ heures: 45 });
});
});
describe('DELETE /api/v1/tables/:tableId/rows/:rowId', () => {
it('204 + invalide cache', async () => {
const rows = new FakeRowsRepo();
const { app } = bootApp({ rows });
const res = await app.request('/api/v1/tables/5/rows/100', {
method: 'DELETE',
headers: { Authorization: `Bearer ${WRITE_TOKEN}` },
});
expect(res.status).toBe(204);
expect(rows.lastDelete).toEqual({ tableId: 5, rowId: 100 });
});
it('403 sans scope write:tables', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables/5/rows/100', {
method: 'DELETE',
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(403);
});
});
describe('Edge cases /api/v1/tables', () => {
it('400 si tableId = 0 (positif strict)', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables/0/rows', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(400);
});
it('400 si databaseId non numerique', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/tables?databaseId=abc', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(400);
});
it('admin token (admin:*) acces toutes les routes', async () => {
const rows = new FakeRowsRepo(
new Map([[5, [new Row({ id: 1, tableId: 5, fields: { x: 1 } })]]]),
);
const { app } = bootApp({ rows });
const res = await app.request('/api/v1/tables/5/rows', {
headers: { Authorization: `Bearer ${ADMIN_TOKEN}` },
});
expect(res.status).toBe(200);
});
it('GET rows avec search query param est passe au repo', async () => {
const rows = new FakeRowsRepo();
const repo: typeof rows & { lastListSearch?: string } = rows as typeof rows & {
lastListSearch?: string;
};
const orig = rows.list.bind(rows);
rows.list = (tableId: number, opts?: { search?: string }) => {
repo.lastListSearch = opts?.search;
return orig(tableId);
};
const { app } = bootApp({ rows });
await app.request('/api/v1/tables/5/rows?search=alice', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(repo.lastListSearch).toBe('alice');
});
it('GET tables/:id renvoie les fields meme si DB token (fields use DB token)', async () => {
const tables = new FakeTablesRepo(
new Map(),
new Map([[42, new Table({ id: 42, name: 'X', databaseId: 5 })]]),
);
const fields = new FakeFieldsRepo(
new Map([
[
42,
[
new Field({ id: 1, name: 'a', type: 'text', primary: true }),
new Field({ id: 2, name: 'b', type: 'number' }),
],
],
]),
);
const { app } = bootApp({ tables, fields });
const res = await app.request('/api/v1/tables/42', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
const body = (await res.json()) as { data: { fields: Array<{ name: string }> } };
expect(body.data.fields).toHaveLength(2);
});
it('GET tables/:id 501 si DB token sur lecture metadata', async () => {
const tables = new FakeTablesRepo();
tables.failOnGet = true;
const fields = new FakeFieldsRepo();
const { app } = bootApp({ tables, fields });
const res = await app.request('/api/v1/tables/42', {
headers: { Authorization: `Bearer ${READ_TOKEN}` },
});
expect(res.status).toBe(501);
});
});