/** * 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 = new Map(), public tablesById: Map = new Map(), ) {} async list(databaseId: number): Promise { this.listCalls.push(databaseId); if (this.failOnList) { throw errors.authInvalid(); } return this.tablesByDb.get(databaseId) ?? []; } async get(tableId: number): Promise { 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 = new Map()) {} async list(tableId: number): Promise { return this.fieldsByTable.get(tableId) ?? []; } } class FakeViewsRepo { constructor( public viewsByTable: Map = new Map(), public rowsByView: Map = new Map(), ) {} async list(tableId: number): Promise { 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 }; public lastUpdate?: { tableId: number; rowId: number; fields: Record }; public lastDelete?: { tableId: number; rowId: number }; public nextId = 1000; constructor(public rowsByTable: Map = 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 { 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): Promise { this.lastCreate = { tableId, fields }; const id = this.nextId++; return new Row({ id, tableId, fields }); } async update(tableId: number, rowId: number, fields: Record): Promise { this.lastUpdate = { tableId, rowId, fields }; return new Row({ id: rowId, tableId, fields }); } async delete(tableId: number, rowId: number): Promise { 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[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 }>; }; 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 }; }; 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 } }; 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); }); });