/** * Tests integration des routes /api/v1/views/* — R3.1.a database-view. * * Repos faked en memoire — pas d'appel reseau, Redis noop. */ import { afterEach, describe, expect, it } from 'vitest'; import { Row } from '../../src/domain/row.js'; import { View } from '../../src/domain/view.js'; import type { RepoSet } from '../../src/lib/container.js'; import { errors } from '../../src/lib/errors.js'; import type { ViewDataResult } from '../../src/repos/baserow-views-repo.js'; import { ADMIN_TOKEN, READ_TOKEN, WRITE_TOKEN, buildTestApp, installTestContainer, resetTestContainer, } from '../helpers/test-app.js'; // --------------------------------------------------------------------------- // Fake repos // --------------------------------------------------------------------------- class FakeTablesRepo { async list(_databaseId: number) { return []; } async get(_tableId: number) { throw errors.notFound('Table', _tableId); } } class FakeFieldsRepo { async list(_tableId: number) { return []; } } class FakeRowsRepo { async list(_tableId: number) { return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } }; } async get(_tableId: number, rowId: number): Promise { throw errors.notFound('Row', rowId); } async create(tableId: number, fields: Record): Promise { return new Row({ id: 1, tableId, fields }); } async update(tableId: number, rowId: number, fields: Record): Promise { return new Row({ id: rowId, tableId, fields }); } async delete(_tableId: number, _rowId: number): Promise {} } /** * Fake views repo avec controle fin des methodes listByTable / getViewData. */ class FakeViewsRepo { public listByTableCalls: number[] = []; public getViewDataCalls: Array<{ viewId: number; tableId: number; page: number; size: number; search?: string; }> = []; public failOnListByTable = false; public failOnGetViewData = false; constructor( private viewsByTable: Map = new Map(), private viewDataByView: Map = new Map(), ) {} // Compat /tables/:id/views async list(tableId: number): Promise { return this.viewsByTable.get(tableId) ?? []; } // Compat /tables/:id/views/:viewId/rows async runGrid(viewId: number, _tableId: number) { const items = this.viewDataByView.get(viewId)?.items ?? []; return { items, meta: { page: 1, per_page: 50, total: items.length, total_pages: 1 } }; } // R3.1.a async listByTable(tableId: number): Promise { this.listByTableCalls.push(tableId); if (this.failOnListByTable) { throw errors.baserowDown(); } return this.viewsByTable.get(tableId) ?? []; } // R3.1.a async getViewData( viewId: number, tableId: number, opts: { page?: number; size?: number; search?: string } = {}, ): Promise { this.getViewDataCalls.push({ viewId, tableId, page: opts.page ?? 1, size: opts.size ?? 100, search: opts.search, }); if (this.failOnGetViewData) { throw errors.baserowDown(); } const result = this.viewDataByView.get(viewId); if (!result) { return { items: [], meta: { page: opts.page ?? 1, per_page: opts.size ?? 100, total: 0, total_pages: 1 }, viewType: 'grid', }; } return result; } } function buildFakeRepos(views?: FakeViewsRepo): RepoSet { return { tables: new FakeTablesRepo() as unknown as RepoSet['tables'], fields: new FakeFieldsRepo() as unknown as RepoSet['fields'], views: (views ?? new FakeViewsRepo()) as unknown as RepoSet['views'], rows: new FakeRowsRepo() as unknown as RepoSet['rows'], }; } function bootApp(views?: FakeViewsRepo) { const repos = buildFakeRepos(views); const container = installTestContainer({ repos }); return { app: buildTestApp(container), views }; } afterEach(resetTestContainer); // --------------------------------------------------------------------------- // GET /api/v1/views/table/:tableId // --------------------------------------------------------------------------- describe('GET /api/v1/views/table/:tableId', () => { it('401 sans token', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/table/5'); expect(res.status).toBe(401); }); it('403 sans scope read:tables', async () => { // Pour tester 403, on cree un container avec un token sans read:tables. const repos = buildFakeRepos(); const container = installTestContainer({ repos, tokens: [{ token: 'brg_noread', name: 'no-read', scopes: ['write:tables'] }], }); const testApp = buildTestApp(container); const res = await testApp.request('/api/v1/views/table/5', { headers: { Authorization: 'Bearer brg_noread' }, }); expect(res.status).toBe(403); }); it('400 si tableId non numerique', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/table/abc', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(400); }); it('400 si tableId = 0', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/table/0', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(400); }); it('200 liste vide', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/table/99', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { data: unknown[]; total: number }; expect(body.data).toHaveLength(0); expect(body.total).toBe(0); }); it('200 liste vues avec metadata completes', async () => { const views = new FakeViewsRepo( new Map([ [ 5, [ new View({ id: 100, name: 'Tous', type: 'grid', tableId: 5, order: 0, filters: [{ id: 1, field: 10, type: 'equal', value: 'actif' }], sortings: [{ id: 2, field: 10, order: 'ASC' }], groupBys: [], }), new View({ id: 101, name: 'Kanban sprint', type: 'kanban', tableId: 5, order: 1 }), ], ], ]), ); const { app } = bootApp(views); const res = await app.request('/api/v1/views/table/5', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { data: Array<{ id: number; tableId: number; name: string; type: string; order: number; filters: unknown[]; sortings: unknown[]; groupBys: unknown[]; }>; total: number; }; expect(body.total).toBe(2); expect(body.data[0]?.id).toBe(100); expect(body.data[0]?.tableId).toBe(5); expect(body.data[0]?.filters).toHaveLength(1); expect(body.data[0]?.sortings).toHaveLength(1); expect(body.data[0]?.groupBys).toHaveLength(0); expect(body.data[1]?.type).toBe('kanban'); }); it('200 admin token fonctionne', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/table/5', { headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, }); expect(res.status).toBe(200); }); it('502 si Baserow indisponible', async () => { const views = new FakeViewsRepo(); views.failOnListByTable = true; const { app } = bootApp(views); const res = await app.request('/api/v1/views/table/5', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(502); }); it('appelle listByTable avec le bon tableId', async () => { const views = new FakeViewsRepo(); const { app } = bootApp(views); await app.request('/api/v1/views/table/42', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(views.listByTableCalls).toContain(42); }); }); // --------------------------------------------------------------------------- // GET /api/v1/views/:viewId/data // --------------------------------------------------------------------------- describe('GET /api/v1/views/:viewId/data', () => { it('401 sans token', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/100/data?tableId=5'); expect(res.status).toBe(401); }); it('400 si tableId absent', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/100/data', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(400); const body = (await res.json()) as { error: { code: string } }; expect(body.error.code).toBe('VALIDATION_ERROR'); }); it('400 si viewId non numerique', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/abc/data?tableId=5', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(400); }); it('400 si tableId = 0', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/100/data?tableId=0', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(400); }); it('400 si page invalide', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/100/data?tableId=5&page=0', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(400); }); it('200 donnees vides par defaut', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/100/data?tableId=5', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { data: unknown[]; total: number; page: number; size: number; viewType: string; }; expect(body.data).toHaveLength(0); expect(body.total).toBe(0); expect(body.page).toBe(1); expect(body.size).toBe(100); expect(body.viewType).toBe('grid'); }); it('200 donnees avec rows', async () => { const rows = [ new Row({ id: 1, tableId: 5, fields: { nom: 'Alice', statut: 'actif' } }), new Row({ id: 2, tableId: 5, fields: { nom: 'Bob', statut: 'inactif' } }), ]; const views = new FakeViewsRepo( new Map(), new Map([ [ 100, { items: rows, meta: { page: 1, per_page: 100, total: 2, total_pages: 1 }, viewType: 'grid', }, ], ]), ); const { app } = bootApp(views); const res = await app.request('/api/v1/views/100/data?tableId=5', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { data: Array<{ id: number; fields: Record }>; total: number; }; expect(body.data).toHaveLength(2); expect(body.data[0]?.id).toBe(1); expect(body.data[0]?.fields.nom).toBe('Alice'); expect(body.total).toBe(2); }); it('transmet page, size et search au repo', async () => { const views = new FakeViewsRepo(); const { app } = bootApp(views); await app.request('/api/v1/views/100/data?tableId=5&page=2&size=50&search=alice', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(views.getViewDataCalls).toHaveLength(1); expect(views.getViewDataCalls[0]).toMatchObject({ viewId: 100, tableId: 5, page: 2, size: 50, search: 'alice', }); }); it('size cap a 200', async () => { const views = new FakeViewsRepo(); const { app } = bootApp(views); await app.request('/api/v1/views/100/data?tableId=5&size=9999', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(views.getViewDataCalls[0]?.size).toBe(200); }); it('403 sans scope read:tables', async () => { const repos = buildFakeRepos(); const container = installTestContainer({ repos, tokens: [{ token: 'brg_noread', name: 'no-read', scopes: ['write:tables'] }], }); const testApp = buildTestApp(container); const res = await testApp.request('/api/v1/views/100/data?tableId=5', { headers: { Authorization: 'Bearer brg_noread' }, }); expect(res.status).toBe(403); }); it('200 admin token fonctionne', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/100/data?tableId=5', { headers: { Authorization: `Bearer ${ADMIN_TOKEN}` }, }); expect(res.status).toBe(200); }); it('502 si Baserow indisponible', async () => { const views = new FakeViewsRepo(); views.failOnGetViewData = true; const { app } = bootApp(views); const res = await app.request('/api/v1/views/100/data?tableId=5', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(502); }); it('WRITE_TOKEN (qui a read:tables) fonctionne en GET', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/100/data?tableId=5', { headers: { Authorization: `Bearer ${WRITE_TOKEN}` }, }); expect(res.status).toBe(200); }); });