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
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>
431 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|