Wiki/bridge/tests/routes/views.test.ts
Corentin JOGUET 95089c460c
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): add views endpoints for R3.1.a database-view
Two new endpoints under /api/v1/views:
  GET /api/v1/views/table/:tableId  — list views for a table with Redis
    cache TTL 60s. Returns full view metadata (filters, sortings, groupBys,
    order). Cache invalidated by view.created|updated|deleted webhook events.
  GET /api/v1/views/:viewId/data    — paginated rows of a view applying
    Baserow view filters/sorts via ?view_id= query param. Redis cache TTL 30s
    keyed by (viewId, page, size, search). Requires tableId query param.

Domain: View entity extended with order, filters, sortings, groupBys.
Adapter: BaserowListOptions gains viewId param (forwards to Baserow ?view_id=).
Webhook: baserow-handler extended for view.* events — invalidates views:table
  and views:data cache keys. rows.* events now also invalidate views:data:*.
Tests: +44 tests (336 total, was 292). Routes 20, repo 20, webhook 4.
Coverage: view.ts 100%, routes/views.ts 100% lines, baserow-handler 100%.

Co-Authored-By: Amelia (bmad-bmm-dev BYAN) <noreply@anthropic.com>
2026-05-07 23:24:10 +02:00

431 lines
13 KiB
TypeScript

/**
* 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<Row> {
throw errors.notFound('Row', rowId);
}
async create(tableId: number, fields: Record<string, unknown>): Promise<Row> {
return new Row({ id: 1, tableId, fields });
}
async update(tableId: number, rowId: number, fields: Record<string, unknown>): Promise<Row> {
return new Row({ id: rowId, tableId, fields });
}
async delete(_tableId: number, _rowId: number): Promise<void> {}
}
/**
* 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<number, View[]> = new Map(),
private viewDataByView: Map<number, ViewDataResult> = new Map(),
) {}
// Compat /tables/:id/views
async list(tableId: number): Promise<View[]> {
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<View[]> {
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<ViewDataResult> {
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<string, unknown> }>;
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);
});
});