feat(bridge): add views endpoints for R3.1.a database-view
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
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>
This commit is contained in:
parent
19246c6fe7
commit
95089c460c
11 changed files with 1224 additions and 24 deletions
|
|
@ -27,6 +27,8 @@ export interface BaserowListOptions {
|
|||
filter?: Record<string, string | number | boolean>;
|
||||
orderBy?: string;
|
||||
userFieldNames?: boolean;
|
||||
/** R3.1.a : view_id applique les filtres/sorts/groupBy declares sur la vue. */
|
||||
viewId?: number;
|
||||
}
|
||||
|
||||
export class BaserowClient {
|
||||
|
|
@ -83,6 +85,7 @@ export class BaserowClient {
|
|||
};
|
||||
if (opts.search) params.search = opts.search;
|
||||
if (opts.orderBy) params.order_by = opts.orderBy;
|
||||
if (opts.viewId) params.view_id = opts.viewId;
|
||||
if (opts.filter) {
|
||||
for (const [key, val] of Object.entries(opts.filter)) {
|
||||
params[`filter__${key}__contains`] = String(val);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,42 @@
|
|||
/**
|
||||
* View entity — vue Baserow (grid, kanban, calendar, gallery, form).
|
||||
*
|
||||
* On expose juste id, name, type et tableId proprietaire. Les filtres et tris
|
||||
* de la vue sont resolus cote Baserow quand on lit `/views/grid/:id/`.
|
||||
* R3.1.a : on expose egalement les filtres, tris et regroupements declares sur
|
||||
* la vue. Ces metadonnees sont lues depuis l'API Baserow et caches cote bridge.
|
||||
* La resolution effective des donnees (rows) tient compte de ces parametres
|
||||
* via l'endpoint `/api/database/rows/table/:id/?view_id=:vid` de Baserow.
|
||||
*/
|
||||
|
||||
export type ViewType = 'grid' | 'kanban' | 'calendar' | 'gallery' | 'form' | string;
|
||||
|
||||
export interface ViewFilter {
|
||||
id: number;
|
||||
field: number;
|
||||
type: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ViewSorting {
|
||||
id: number;
|
||||
field: number;
|
||||
order: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface ViewGroupBy {
|
||||
id: number;
|
||||
field: number;
|
||||
order: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
export interface ViewProps {
|
||||
id: number;
|
||||
name: string;
|
||||
type: ViewType;
|
||||
tableId: number;
|
||||
order?: number;
|
||||
filters?: ViewFilter[];
|
||||
sortings?: ViewSorting[];
|
||||
groupBys?: ViewGroupBy[];
|
||||
}
|
||||
|
||||
export class View {
|
||||
|
|
@ -19,11 +44,19 @@ export class View {
|
|||
readonly name: string;
|
||||
readonly type: ViewType;
|
||||
readonly tableId: number;
|
||||
readonly order: number;
|
||||
readonly filters: ViewFilter[];
|
||||
readonly sortings: ViewSorting[];
|
||||
readonly groupBys: ViewGroupBy[];
|
||||
|
||||
constructor(props: ViewProps) {
|
||||
this.id = props.id;
|
||||
this.name = props.name;
|
||||
this.type = props.type;
|
||||
this.tableId = props.tableId;
|
||||
this.order = props.order ?? 0;
|
||||
this.filters = props.filters ?? [];
|
||||
this.sortings = props.sortings ?? [];
|
||||
this.groupBys = props.groupBys ?? [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { type AuthVariables, authMiddleware } from './middleware/auth.js';
|
|||
import { errorHandler } from './middleware/error-handler.js';
|
||||
import { defaultRateLimitKey, rateLimit } from './middleware/rate-limit.js';
|
||||
import { tablesRoutes } from './routes/tables.js';
|
||||
import { viewsRoutes } from './routes/views.js';
|
||||
import { webhooksRoutes } from './routes/webhooks.js';
|
||||
|
||||
export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
||||
|
|
@ -79,6 +80,8 @@ export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
|
|||
await next();
|
||||
});
|
||||
v1.route('/tables', tablesRoutes);
|
||||
// R3.1.a : routes views — liste vues par table + donnees paginées d'une vue.
|
||||
v1.route('/views', viewsRoutes);
|
||||
app.route('/api/v1', v1);
|
||||
|
||||
app.notFound((c) => c.json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404));
|
||||
|
|
|
|||
|
|
@ -1,13 +1,23 @@
|
|||
/**
|
||||
* Repository views Baserow — list par tableId + run grid view.
|
||||
* Repository views Baserow — list par tableId + donnees d'une vue.
|
||||
*
|
||||
* `runGridView` execute la vue avec ses filtres/sorts Baserow et retourne les
|
||||
* rows mappees. DB token OK.
|
||||
* R3.1.a : deux methodes principales :
|
||||
* - `listByTable(tableId, redis?)` : liste les vues avec cache Redis TTL 60s.
|
||||
* Cle : `views:table:{tableId}`. Invalidation via webhook `view.*`.
|
||||
* - `getViewData(viewId, tableId, params, redis?)` : recupere les rows d'une
|
||||
* vue via `GET /api/database/rows/table/:id/?view_id=:vid`. Baserow applique
|
||||
* les filtres, tris et regroupements declares sur la vue. Cache Redis TTL 30s.
|
||||
* Cle : `views:data:{viewId}:{page}:{size}:{search}`.
|
||||
*
|
||||
* `runGrid` conserve pour compat : utilise le endpoint grid-specifique
|
||||
* `/api/database/views/grid/:id/` qui supporte uniquement les vues de type grid.
|
||||
*/
|
||||
|
||||
import type { Logger } from 'pino';
|
||||
import type { BaserowClient, BaserowListOptions } from '../adapters/baserow-client.js';
|
||||
import type { RedisCache } from '../adapters/redis-cache.js';
|
||||
import { Row } from '../domain/row.js';
|
||||
import type { ViewFilter, ViewGroupBy, ViewSorting } from '../domain/view.js';
|
||||
import { View } from '../domain/view.js';
|
||||
|
||||
export interface BaserowViewsRepoOptions {
|
||||
|
|
@ -25,6 +35,103 @@ export interface ListRowsResult {
|
|||
};
|
||||
}
|
||||
|
||||
export interface ViewDataResult extends ListRowsResult {
|
||||
viewType: string;
|
||||
}
|
||||
|
||||
const VIEWS_LIST_TTL = 60;
|
||||
const VIEW_DATA_TTL = 30;
|
||||
|
||||
function buildViewsListCacheKey(tableId: number): string {
|
||||
return `views:table:${tableId}`;
|
||||
}
|
||||
|
||||
function buildViewDataCacheKey(
|
||||
viewId: number,
|
||||
page: number,
|
||||
size: number,
|
||||
search: string | undefined,
|
||||
): string {
|
||||
return `views:data:${viewId}:${page}:${size}:${search ?? ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mappe le raw Baserow d'une vue vers notre domaine. Baserow retourne les
|
||||
* filtres/sortings dans des sous-tableaux optionnels selon le type de vue.
|
||||
*/
|
||||
function mapRawToView(
|
||||
r: { id: number; name: string; type: string; table_id: number; order?: number } & Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
): View {
|
||||
const filters: ViewFilter[] = [];
|
||||
if (Array.isArray(r.filters)) {
|
||||
for (const f of r.filters) {
|
||||
if (
|
||||
typeof f === 'object' &&
|
||||
f !== null &&
|
||||
typeof (f as Record<string, unknown>).id === 'number'
|
||||
) {
|
||||
const raw = f as Record<string, unknown>;
|
||||
filters.push({
|
||||
id: raw.id as number,
|
||||
field: raw.field as number,
|
||||
type: (raw.type as string) ?? 'equal',
|
||||
value: String(raw.value ?? ''),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sortings: ViewSorting[] = [];
|
||||
if (Array.isArray(r.sortings)) {
|
||||
for (const s of r.sortings) {
|
||||
if (
|
||||
typeof s === 'object' &&
|
||||
s !== null &&
|
||||
typeof (s as Record<string, unknown>).id === 'number'
|
||||
) {
|
||||
const raw = s as Record<string, unknown>;
|
||||
sortings.push({
|
||||
id: raw.id as number,
|
||||
field: raw.field as number,
|
||||
order: raw.order === 'DESC' ? 'DESC' : 'ASC',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const groupBys: ViewGroupBy[] = [];
|
||||
if (Array.isArray(r.group_bys)) {
|
||||
for (const g of r.group_bys) {
|
||||
if (
|
||||
typeof g === 'object' &&
|
||||
g !== null &&
|
||||
typeof (g as Record<string, unknown>).id === 'number'
|
||||
) {
|
||||
const raw = g as Record<string, unknown>;
|
||||
groupBys.push({
|
||||
id: raw.id as number,
|
||||
field: raw.field as number,
|
||||
order: raw.order === 'DESC' ? 'DESC' : 'ASC',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new View({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
type: r.type,
|
||||
tableId: r.table_id,
|
||||
order: typeof r.order === 'number' ? r.order : 0,
|
||||
filters,
|
||||
sortings,
|
||||
groupBys,
|
||||
});
|
||||
}
|
||||
|
||||
export class BaserowViewsRepo {
|
||||
protected readonly client: BaserowClient;
|
||||
protected readonly logger: Logger;
|
||||
|
|
@ -34,19 +141,117 @@ export class BaserowViewsRepo {
|
|||
this.logger = opts.logger.child({ repo: 'views' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste les vues d'une table.
|
||||
* Compat R1 — sans cache Redis, retourne View[] simple.
|
||||
*/
|
||||
async list(tableId: number): Promise<View[]> {
|
||||
const raws = await this.client.listViews(tableId);
|
||||
return raws.map(
|
||||
(r) =>
|
||||
new View({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
type: r.type,
|
||||
tableId: r.table_id,
|
||||
}),
|
||||
);
|
||||
return raws.map(mapRawToView);
|
||||
}
|
||||
|
||||
/**
|
||||
* R3.1.a — Liste les vues d'une table avec cache Redis TTL 60s.
|
||||
* Si redis absent (tests unitaires sans Redis), bypass le cache.
|
||||
*/
|
||||
async listByTable(tableId: number, redis?: RedisCache): Promise<View[]> {
|
||||
const cacheKey = buildViewsListCacheKey(tableId);
|
||||
|
||||
if (redis) {
|
||||
const cached = await redis.get<unknown[]>(cacheKey);
|
||||
if (cached !== null) {
|
||||
this.logger.debug({ tableId, cacheKey }, 'views list cache hit');
|
||||
// Les View sont serialisees comme plain objects — on reconstruit les instances.
|
||||
return cached.map((v) => {
|
||||
const raw = v as ViewProps;
|
||||
return new View(raw);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const raws = await this.client.listViews(tableId);
|
||||
const views = raws.map(mapRawToView);
|
||||
|
||||
if (redis) {
|
||||
await redis.set(cacheKey, views, VIEWS_LIST_TTL);
|
||||
this.logger.debug({ tableId, cacheKey, count: views.length }, 'views list cached');
|
||||
}
|
||||
|
||||
return views;
|
||||
}
|
||||
|
||||
/**
|
||||
* R3.1.a — Donnees d'une vue (rows appliquant filtres/sorts/groupBy de la vue).
|
||||
*
|
||||
* Utilise `GET /api/database/rows/table/:tableId/?view_id=:viewId` — endpoint
|
||||
* Baserow qui applique les parametres de la vue quel que soit son type.
|
||||
* Cache Redis TTL 30s par (viewId, page, size, search).
|
||||
*/
|
||||
async getViewData(
|
||||
viewId: number,
|
||||
tableId: number,
|
||||
opts: BaserowListOptions & { redis?: RedisCache } = {},
|
||||
): Promise<ViewDataResult> {
|
||||
const page = opts.page ?? 1;
|
||||
const size = Math.min(opts.size ?? 100, 200);
|
||||
const search = opts.search;
|
||||
const { redis, ...listOpts } = opts;
|
||||
|
||||
const cacheKey = buildViewDataCacheKey(viewId, page, size, search);
|
||||
|
||||
if (redis) {
|
||||
const cached = await redis.get<ViewDataResult>(cacheKey);
|
||||
if (cached !== null) {
|
||||
this.logger.debug({ viewId, cacheKey }, 'view data cache hit');
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Baserow: GET /api/database/rows/table/:id/?view_id=:vid applique les
|
||||
// filtres et tris declares sur la vue. Fonctionne pour tous les types de vue
|
||||
// (contrairement a /views/grid/:id/ qui est grid-only).
|
||||
const res = await this.client.listRows(tableId, {
|
||||
...listOpts,
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
viewId,
|
||||
});
|
||||
|
||||
const items = res.results.map((r) => {
|
||||
const { id, order, ...fields } = r;
|
||||
return new Row({
|
||||
id,
|
||||
tableId,
|
||||
fields,
|
||||
order: typeof order === 'string' ? order : null,
|
||||
});
|
||||
});
|
||||
|
||||
const result: ViewDataResult = {
|
||||
items,
|
||||
meta: {
|
||||
page,
|
||||
per_page: size,
|
||||
total: res.count,
|
||||
total_pages: Math.max(1, Math.ceil(res.count / size)),
|
||||
},
|
||||
viewType: 'unknown',
|
||||
};
|
||||
|
||||
if (redis) {
|
||||
await redis.set(cacheKey, result, VIEW_DATA_TTL);
|
||||
this.logger.debug({ viewId, cacheKey }, 'view data cached');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compat R1 — execute la grid view via l'endpoint grid-specifique.
|
||||
* Pour de nouvelles fonctionnalites, preferer `getViewData` qui supporte
|
||||
* tous les types de vue via view_id query param.
|
||||
*/
|
||||
async runGrid(
|
||||
viewId: number,
|
||||
tableId: number,
|
||||
|
|
@ -75,3 +280,6 @@ export class BaserowViewsRepo {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export pour usage dans mapRawToView sans import circulaire.
|
||||
type ViewProps = ConstructorParameters<typeof View>[0];
|
||||
|
|
|
|||
131
bridge/src/routes/views.ts
Normal file
131
bridge/src/routes/views.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* Routes /api/views — R3.1.a database-view.
|
||||
*
|
||||
* Deux endpoints :
|
||||
* GET /api/views/table/:tableId — liste les vues d'une table avec cache Redis
|
||||
* GET /api/views/:viewId/data — donnees paginées d'une vue (filters/sort/group
|
||||
* appliques par Baserow via view_id param)
|
||||
*
|
||||
* Separation de /api/v1/tables/* voulue : les routes `tables` sont des metadata
|
||||
* generiques (CRUD rows/fields/views sans cache), les routes `views` ici sont
|
||||
* des endpoints specialises R3.1.a avec cache et pagination orientee "database
|
||||
* view" style Notion.
|
||||
*
|
||||
* Permissions mappees sur les scopes generiques du bridge :
|
||||
* - `database.tables.read` dans `acadenice_permissions[]` est traite comme
|
||||
* `read:tables` par le RBAC DocAdenice avant emission du JWT. On utilise
|
||||
* donc `requireScope('read:tables')` pour rester coherent avec le systeme
|
||||
* existant. De meme `database.rows.read` -> `read:tables` (meme scope
|
||||
* car les rows sont lues en contexte de table).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Row } from '../domain/row.js';
|
||||
import type { View } from '../domain/view.js';
|
||||
import { getContainer } from '../lib/container.js';
|
||||
import { errors } from '../lib/errors.js';
|
||||
import { type AuthVariables, requireScope } from '../middleware/auth.js';
|
||||
|
||||
export const viewsRoutes = new Hono<{ Variables: AuthVariables }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Serialisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function serializeView(v: View) {
|
||||
return {
|
||||
id: v.id,
|
||||
tableId: v.tableId,
|
||||
name: v.name,
|
||||
type: v.type,
|
||||
order: v.order,
|
||||
filters: v.filters,
|
||||
sortings: v.sortings,
|
||||
groupBys: v.groupBys,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeRow(r: Row) {
|
||||
return {
|
||||
id: r.id,
|
||||
tableId: r.tableId,
|
||||
fields: r.fields,
|
||||
order: r.order,
|
||||
createdOn: r.createdOn?.toISOString() ?? null,
|
||||
updatedOn: r.updatedOn?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseIntParam(raw: string, label: string): number {
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(n) || n <= 0) {
|
||||
throw errors.validation([{ message: `${label} must be a positive integer` }]);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function parseIntQuery(url: URL, name: string, defaultVal: number, max?: number): number {
|
||||
const raw = url.searchParams.get(name);
|
||||
if (!raw) return defaultVal;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (Number.isNaN(n) || n <= 0) {
|
||||
throw errors.validation([{ message: `${name} must be a positive integer` }]);
|
||||
}
|
||||
return max !== undefined ? Math.min(n, max) : n;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/views/table/:tableId — liste les vues d'une table (cached)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
viewsRoutes.get('/table/:tableId', requireScope('read:tables'), async (c) => {
|
||||
const tableId = parseIntParam(c.req.param('tableId'), 'tableId');
|
||||
const { repos, redis } = getContainer();
|
||||
|
||||
const views = await repos.views.listByTable(tableId, redis);
|
||||
|
||||
return c.json({ data: views.map(serializeView), total: views.length });
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /api/views/:viewId/data — donnees d'une vue (rows + pagination cached)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
viewsRoutes.get('/:viewId/data', requireScope('read:tables'), async (c) => {
|
||||
const viewId = parseIntParam(c.req.param('viewId'), 'viewId');
|
||||
const { repos, redis } = getContainer();
|
||||
const url = new URL(c.req.url);
|
||||
|
||||
const page = parseIntQuery(url, 'page', 1);
|
||||
const size = parseIntQuery(url, 'size', 100, 200);
|
||||
const search = url.searchParams.get('search') ?? undefined;
|
||||
|
||||
// tableId est requis pour construire les Row instances avec le bon identifiant
|
||||
// de table. Baserow ne retourne pas le tableId dans la reponse listRows, donc
|
||||
// le caller doit le fournir. Cela evite un aller-retour supplementaire pour
|
||||
// aller chercher la vue puis deduire sa table.
|
||||
const tableIdRaw = url.searchParams.get('tableId');
|
||||
if (!tableIdRaw) {
|
||||
throw errors.validation([{ message: 'tableId query param required' }]);
|
||||
}
|
||||
const tableId = parseIntParam(tableIdRaw, 'tableId');
|
||||
|
||||
const result = await repos.views.getViewData(viewId, tableId, {
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
redis,
|
||||
});
|
||||
|
||||
return c.json({
|
||||
data: result.items.map(serializeRow),
|
||||
total: result.meta.total,
|
||||
page: result.meta.page,
|
||||
size: result.meta.per_page,
|
||||
viewType: result.viewType,
|
||||
});
|
||||
});
|
||||
|
|
@ -27,12 +27,13 @@ export interface BaserowHandleResult {
|
|||
}
|
||||
|
||||
/**
|
||||
* Patterns d'invalidation cache pour une table.
|
||||
* Patterns d'invalidation cache pour les events `rows.*`.
|
||||
* - `list:*` : toutes les listes paginees / filtrees
|
||||
* - `views:*` : toutes les rows fetched via une view
|
||||
* - `views:*` (tables keyspace) : vues fetched via /tables/:id/views
|
||||
* - `row:<id>` : la row precise (si update/delete avec items)
|
||||
* - `views:data:*` (views keyspace R3.1.a) : donnees de vues cachees
|
||||
*/
|
||||
function buildInvalidationPatterns(
|
||||
function buildRowsInvalidationPatterns(
|
||||
tableId: number,
|
||||
eventType: BaserowEventType,
|
||||
itemIds: number[],
|
||||
|
|
@ -40,6 +41,8 @@ function buildInvalidationPatterns(
|
|||
const patterns: string[] = [
|
||||
`bridge:tables:${tableId}:list:*`,
|
||||
`bridge:tables:${tableId}:views:*`,
|
||||
// R3.1.a : les donnees de toutes les vues de cette table peuvent etre affectees.
|
||||
'views:data:*',
|
||||
];
|
||||
|
||||
if (eventType === 'rows.updated' || eventType === 'rows.deleted') {
|
||||
|
|
@ -51,6 +54,25 @@ function buildInvalidationPatterns(
|
|||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* R3.1.a : Patterns d'invalidation pour les events `view.*`.
|
||||
* - Liste des vues de la table concernee.
|
||||
* - Si view_id fourni : donnees cachees de cette vue precise.
|
||||
*/
|
||||
function buildViewInvalidationPatterns(tableId: number, viewId: number | undefined): string[] {
|
||||
const patterns: string[] = [
|
||||
// La liste des vues de cette table est stale.
|
||||
`views:table:${tableId}`,
|
||||
];
|
||||
|
||||
if (viewId !== undefined) {
|
||||
// Toutes les pages cachees de cette vue sont invalides.
|
||||
patterns.push(`views:data:${viewId}:*`);
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
export async function handleBaserowEvent(
|
||||
payload: BaserowWebhookPayload,
|
||||
deps: BaserowHandlerDeps,
|
||||
|
|
@ -63,8 +85,20 @@ export async function handleBaserowEvent(
|
|||
return { status: 'ignored', tableId: null, invalidatedKeys: 0 };
|
||||
}
|
||||
|
||||
const itemIds = payload.items.map((i) => i.id);
|
||||
const patterns = buildInvalidationPatterns(payload.table_id, payload.event_type, itemIds);
|
||||
const isViewEvent =
|
||||
payload.event_type === 'view.created' ||
|
||||
payload.event_type === 'view.updated' ||
|
||||
payload.event_type === 'view.deleted';
|
||||
|
||||
const patterns = isViewEvent
|
||||
? buildViewInvalidationPatterns(payload.table_id, payload.view_id)
|
||||
: buildRowsInvalidationPatterns(
|
||||
payload.table_id,
|
||||
payload.event_type,
|
||||
payload.items.map((i) => i.id),
|
||||
);
|
||||
|
||||
const itemIds = isViewEvent ? [] : payload.items.map((i) => i.id);
|
||||
|
||||
let total = 0;
|
||||
for (const pattern of patterns) {
|
||||
|
|
@ -77,6 +111,7 @@ export async function handleBaserowEvent(
|
|||
eventId: payload.event_id,
|
||||
eventType: payload.event_type,
|
||||
tableId: payload.table_id,
|
||||
viewId: payload.view_id,
|
||||
itemIds,
|
||||
patternsApplied: patterns.length,
|
||||
keysInvalidated: total,
|
||||
|
|
|
|||
|
|
@ -6,13 +6,23 @@
|
|||
|
||||
import { z } from 'zod';
|
||||
|
||||
export const BaserowEventTypeSchema = z.enum(['rows.created', 'rows.updated', 'rows.deleted']);
|
||||
export const BaserowEventTypeSchema = z.enum([
|
||||
'rows.created',
|
||||
'rows.updated',
|
||||
'rows.deleted',
|
||||
// R3.1.a : events de mutation de vue (filtres/sorts/groupBy changes).
|
||||
'view.created',
|
||||
'view.updated',
|
||||
'view.deleted',
|
||||
]);
|
||||
export type BaserowEventType = z.infer<typeof BaserowEventTypeSchema>;
|
||||
|
||||
export const BaserowWebhookPayloadSchema = z.object({
|
||||
event_id: z.string().min(1),
|
||||
event_type: BaserowEventTypeSchema,
|
||||
table_id: z.number().int().positive(),
|
||||
// R3.1.a : view_id present sur les events view.created/updated/deleted.
|
||||
view_id: z.number().int().positive().optional(),
|
||||
// items optionnel : Baserow peut envoyer un test ping sans items.
|
||||
items: z
|
||||
.array(z.object({ id: z.number().int().positive() }).passthrough())
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from '../../src/middleware/auth.js';
|
||||
import { errorHandler } from '../../src/middleware/error-handler.js';
|
||||
import { tablesRoutes } from '../../src/routes/tables.js';
|
||||
import { viewsRoutes } from '../../src/routes/views.js';
|
||||
import { webhooksRoutes } from '../../src/routes/webhooks.js';
|
||||
|
||||
export const READ_TOKEN = 'brg_read';
|
||||
|
|
@ -77,6 +78,7 @@ export function installTestContainer(over: TestContainerOverrides): Container {
|
|||
repos: over.repos,
|
||||
tokens: tokensMap,
|
||||
oidc: null,
|
||||
docmostJwt: null,
|
||||
groupsScopesMap: {},
|
||||
logger,
|
||||
};
|
||||
|
|
@ -103,11 +105,13 @@ export function buildTestApp(container: Container): Hono<{ Variables: AuthVariab
|
|||
authMiddleware({
|
||||
tokens: container.tokens,
|
||||
oidc: container.oidc,
|
||||
docmostJwt: container.docmostJwt,
|
||||
groupsScopesMap: container.groupsScopesMap,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
v1.route('/tables', tablesRoutes);
|
||||
v1.route('/views', viewsRoutes);
|
||||
app.route('/api/v1', v1);
|
||||
|
||||
return app;
|
||||
|
|
|
|||
293
bridge/tests/repos/baserow-views-repo-r3.test.ts
Normal file
293
bridge/tests/repos/baserow-views-repo-r3.test.ts
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
/**
|
||||
* Tests unitaires BaserowViewsRepo — R3.1.a : listByTable + getViewData.
|
||||
*
|
||||
* Mock du BaserowClient via vi.mock (pas d'appel reseau).
|
||||
* Mock du RedisCache pour valider le comportement cache-aside.
|
||||
*/
|
||||
|
||||
import pino from 'pino';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { BaserowClient, BaserowRow } from '../../src/adapters/baserow-client.js';
|
||||
import type { RedisCache } from '../../src/adapters/redis-cache.js';
|
||||
import { View } from '../../src/domain/view.js';
|
||||
import { BaserowViewsRepo } from '../../src/repos/baserow-views-repo.js';
|
||||
|
||||
const silentLogger = () => pino({ level: 'silent' });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RawView = { id: number; name: string; type: string; table_id: number } & Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
function makeClient(views: RawView[] = [], rows: BaserowRow[] = []): BaserowClient {
|
||||
return {
|
||||
listViews: vi.fn().mockResolvedValue(views),
|
||||
listRows: vi.fn().mockResolvedValue({
|
||||
count: rows.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: rows,
|
||||
}),
|
||||
getGridViewRows: vi.fn().mockResolvedValue({
|
||||
count: rows.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: rows,
|
||||
}),
|
||||
} as unknown as BaserowClient;
|
||||
}
|
||||
|
||||
function makeRedis(cached: unknown = null): RedisCache & { setCalls: unknown[][] } {
|
||||
const setCalls: unknown[][] = [];
|
||||
return {
|
||||
get: vi.fn().mockResolvedValue(cached),
|
||||
set: vi.fn().mockImplementation(async (...args: unknown[]) => {
|
||||
setCalls.push(args);
|
||||
}),
|
||||
setCalls,
|
||||
} as unknown as RedisCache & { setCalls: unknown[][] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listByTable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BaserowViewsRepo.listByTable', () => {
|
||||
it('appelle Baserow et retourne des View instances', async () => {
|
||||
const client = makeClient([
|
||||
{ id: 100, name: 'Tous', type: 'grid', table_id: 5 },
|
||||
{ id: 101, name: 'Kanban', type: 'kanban', table_id: 5 },
|
||||
]);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const views = await repo.listByTable(5);
|
||||
expect(views).toHaveLength(2);
|
||||
expect(views[0]).toBeInstanceOf(View);
|
||||
expect(views[0]?.type).toBe('grid');
|
||||
expect(views[1]?.type).toBe('kanban');
|
||||
expect(client.listViews).toHaveBeenCalledWith(5);
|
||||
});
|
||||
|
||||
it('cache miss : appelle Baserow puis set Redis', async () => {
|
||||
const client = makeClient([{ id: 100, name: 'V', type: 'grid', table_id: 5 }]);
|
||||
const redis = makeRedis(null);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const views = await repo.listByTable(5, redis as unknown as RedisCache);
|
||||
expect(client.listViews).toHaveBeenCalled();
|
||||
expect(redis.set).toHaveBeenCalledWith('views:table:5', expect.any(Array), 60);
|
||||
expect(views).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('cache hit : retourne depuis Redis sans appel Baserow', async () => {
|
||||
const cachedViews = [
|
||||
{
|
||||
id: 100,
|
||||
name: 'Cached',
|
||||
type: 'grid',
|
||||
tableId: 5,
|
||||
order: 0,
|
||||
filters: [],
|
||||
sortings: [],
|
||||
groupBys: [],
|
||||
},
|
||||
];
|
||||
const client = makeClient();
|
||||
const redis = makeRedis(cachedViews);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const views = await repo.listByTable(5, redis as unknown as RedisCache);
|
||||
expect(client.listViews).not.toHaveBeenCalled();
|
||||
expect(views).toHaveLength(1);
|
||||
expect(views[0]).toBeInstanceOf(View);
|
||||
expect(views[0]?.name).toBe('Cached');
|
||||
});
|
||||
|
||||
it('sans redis bypass le cache', async () => {
|
||||
const client = makeClient([{ id: 100, name: 'V', type: 'grid', table_id: 5 }]);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const views = await repo.listByTable(5);
|
||||
expect(views).toHaveLength(1);
|
||||
expect(client.listViews).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mappe filters depuis le raw Baserow', async () => {
|
||||
const client = makeClient([
|
||||
{
|
||||
id: 100,
|
||||
name: 'Filtered',
|
||||
type: 'grid',
|
||||
table_id: 5,
|
||||
filters: [{ id: 1, field: 10, type: 'equal', value: 'actif' }],
|
||||
sortings: [{ id: 2, field: 10, order: 'DESC' }],
|
||||
group_bys: [{ id: 3, field: 20, order: 'ASC' }],
|
||||
},
|
||||
]);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const views = await repo.listByTable(5);
|
||||
expect(views[0]?.filters).toHaveLength(1);
|
||||
expect(views[0]?.filters[0]).toEqual({ id: 1, field: 10, type: 'equal', value: 'actif' });
|
||||
expect(views[0]?.sortings[0]?.order).toBe('DESC');
|
||||
expect(views[0]?.groupBys[0]?.field).toBe(20);
|
||||
});
|
||||
|
||||
it('mappe order depuis le raw Baserow', async () => {
|
||||
const client = makeClient([{ id: 100, name: 'V', type: 'grid', table_id: 5, order: 3 }]);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const views = await repo.listByTable(5);
|
||||
expect(views[0]?.order).toBe(3);
|
||||
});
|
||||
|
||||
it('defaut order a 0 si absent', async () => {
|
||||
const client = makeClient([{ id: 100, name: 'V', type: 'grid', table_id: 5 }]);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const views = await repo.listByTable(5);
|
||||
expect(views[0]?.order).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// getViewData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BaserowViewsRepo.getViewData', () => {
|
||||
it('appelle Baserow listRows avec view_id et retourne des Row instances', async () => {
|
||||
const rawRows: BaserowRow[] = [
|
||||
{ id: 1, order: '1.0', nom: 'Alice' },
|
||||
{ id: 2, order: '2.0', nom: 'Bob' },
|
||||
];
|
||||
const client = makeClient([], rawRows);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const result = await repo.getViewData(100, 5);
|
||||
expect(client.listRows).toHaveBeenCalledWith(
|
||||
5,
|
||||
expect.objectContaining({ viewId: 100, page: 1, size: 100 }),
|
||||
);
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.items[0]?.id).toBe(1);
|
||||
expect(result.items[0]?.tableId).toBe(5);
|
||||
expect(result.items[0]?.fields.nom).toBe('Alice');
|
||||
});
|
||||
|
||||
it('transmet page et size a Baserow', async () => {
|
||||
const client = makeClient([], []);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
await repo.getViewData(100, 5, { page: 3, size: 50 });
|
||||
expect(client.listRows).toHaveBeenCalledWith(5, expect.objectContaining({ page: 3, size: 50 }));
|
||||
});
|
||||
|
||||
it('cap size a 200', async () => {
|
||||
const client = makeClient([], []);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
await repo.getViewData(100, 5, { size: 999 });
|
||||
expect(client.listRows).toHaveBeenCalledWith(5, expect.objectContaining({ size: 200 }));
|
||||
});
|
||||
|
||||
it('transmet search a Baserow', async () => {
|
||||
const client = makeClient([], []);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
await repo.getViewData(100, 5, { search: 'alice' });
|
||||
expect(client.listRows).toHaveBeenCalledWith(5, expect.objectContaining({ search: 'alice' }));
|
||||
});
|
||||
|
||||
it('cache miss : appelle Baserow puis set Redis TTL 30', async () => {
|
||||
const rawRows: BaserowRow[] = [{ id: 1, order: '1.0', x: 'y' }];
|
||||
const client = makeClient([], rawRows);
|
||||
const redis = makeRedis(null);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
await repo.getViewData(100, 5, { redis: redis as unknown as RedisCache });
|
||||
expect(client.listRows).toHaveBeenCalled();
|
||||
expect(redis.set).toHaveBeenCalledWith('views:data:100:1:100:', expect.any(Object), 30);
|
||||
});
|
||||
|
||||
it('cache hit : retourne depuis Redis sans appel Baserow', async () => {
|
||||
const cached = {
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
tableId: 5,
|
||||
fields: { nom: 'Cached' },
|
||||
order: null,
|
||||
createdOn: null,
|
||||
updatedOn: null,
|
||||
},
|
||||
],
|
||||
meta: { page: 1, per_page: 100, total: 1, total_pages: 1 },
|
||||
viewType: 'grid',
|
||||
};
|
||||
const client = makeClient([], []);
|
||||
const redis = makeRedis(cached);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const result = await repo.getViewData(100, 5, { redis: redis as unknown as RedisCache });
|
||||
expect(client.listRows).not.toHaveBeenCalled();
|
||||
expect(result.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('cle cache inclut page, size et search', async () => {
|
||||
const client = makeClient([], []);
|
||||
const redis = makeRedis(null);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
await repo.getViewData(100, 5, {
|
||||
page: 2,
|
||||
size: 50,
|
||||
search: 'bob',
|
||||
redis: redis as unknown as RedisCache,
|
||||
});
|
||||
expect(redis.set).toHaveBeenCalledWith('views:data:100:2:50:bob', expect.any(Object), 30);
|
||||
});
|
||||
|
||||
it('sans redis bypass le cache', async () => {
|
||||
const rawRows: BaserowRow[] = [{ id: 1, order: '1.0', x: 'y' }];
|
||||
const client = makeClient([], rawRows);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const result = await repo.getViewData(100, 5);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(client.listRows).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('meta.total_pages calcule correctement', async () => {
|
||||
// 150 rows, size 100 -> 2 pages
|
||||
const client = {
|
||||
listRows: vi.fn().mockResolvedValue({ count: 150, next: null, previous: null, results: [] }),
|
||||
listViews: vi.fn().mockResolvedValue([]),
|
||||
getGridViewRows: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ count: 0, next: null, previous: null, results: [] }),
|
||||
} as unknown as BaserowClient;
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const result = await repo.getViewData(100, 5, { size: 100 });
|
||||
expect(result.meta.total).toBe(150);
|
||||
expect(result.meta.total_pages).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compat: list + runGrid (R1 — pas de regression)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('BaserowViewsRepo.list (compat R1)', () => {
|
||||
it('retourne des View instances sans cache', async () => {
|
||||
const client = makeClient([{ id: 1, name: 'G', type: 'grid', table_id: 5 }]);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const views = await repo.list(5);
|
||||
expect(views).toHaveLength(1);
|
||||
expect(views[0]).toBeInstanceOf(View);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BaserowViewsRepo.runGrid (compat R1)', () => {
|
||||
it('retourne items + meta via getGridViewRows', async () => {
|
||||
const rawRows: BaserowRow[] = [{ id: 10, order: '1.0', col: 'val' }];
|
||||
const client = makeClient([], rawRows);
|
||||
const repo = new BaserowViewsRepo({ client, logger: silentLogger() });
|
||||
const res = await repo.runGrid(200, 5);
|
||||
expect(res.items).toHaveLength(1);
|
||||
expect(res.items[0]?.id).toBe(10);
|
||||
expect(res.meta.total).toBe(1);
|
||||
expect(client.getGridViewRows).toHaveBeenCalledWith(
|
||||
200,
|
||||
expect.objectContaining({ page: 1, size: 50 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
431
bridge/tests/routes/views.test.ts
Normal file
431
bridge/tests/routes/views.test.ts
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -63,13 +63,18 @@ describe('handleBaserowEvent (R1 generique)', () => {
|
|||
expect(redis.calls).toContain('bridge:tables:5:row:200');
|
||||
});
|
||||
|
||||
it('aucune cascade cross-table : tout reste sous bridge:tables:<tableId>:*', async () => {
|
||||
it('aucune cascade cross-table : invalidation limitee a tableId + views:data wildcard', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.updated', table_id: 7, items: [{ id: 1 }] }),
|
||||
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||
);
|
||||
expect(redis.calls.every((p) => p.startsWith('bridge:tables:7:'))).toBe(true);
|
||||
// R3.1.a : views:data:* est un pattern global car on ne connait pas les viewIds
|
||||
// appartenant a la table 7 depuis le payload rows.*.
|
||||
const tablePatterns = redis.calls.filter((p) => p.startsWith('bridge:tables:7:'));
|
||||
expect(tablePatterns).toContain('bridge:tables:7:list:*');
|
||||
expect(tablePatterns).toContain('bridge:tables:7:views:*');
|
||||
expect(redis.calls).toContain('views:data:*');
|
||||
});
|
||||
|
||||
it('table_id <= 0 -> ignored, aucune invalidation', async () => {
|
||||
|
|
@ -94,13 +99,57 @@ describe('handleBaserowEvent (R1 generique)', () => {
|
|||
expect(redis.calls.some((p) => p.includes(':row:'))).toBe(false);
|
||||
});
|
||||
|
||||
// R3.1.a : events view.*
|
||||
it('view.created -> invalide views:table:<tableId> + views:data wildcard', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const res = await handleBaserowEvent(
|
||||
makePayload({ event_type: 'view.created', table_id: 42, view_id: 100, items: [] }),
|
||||
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||
);
|
||||
expect(res.status).toBe('processed');
|
||||
expect(redis.calls).toContain('views:table:42');
|
||||
expect(redis.calls).toContain('views:data:100:*');
|
||||
});
|
||||
|
||||
it('view.updated -> invalide views:table + views:data de la vue precise', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'view.updated', table_id: 5, view_id: 200, items: [] }),
|
||||
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||
);
|
||||
expect(redis.calls).toContain('views:table:5');
|
||||
expect(redis.calls).toContain('views:data:200:*');
|
||||
// Les rows ne sont PAS invalides — c'est une mutation de metadonnees vue.
|
||||
expect(redis.calls.some((p) => p.includes('bridge:tables:'))).toBe(false);
|
||||
});
|
||||
|
||||
it('view.deleted -> invalide views:table (sans view_id precise)', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(makePayload({ event_type: 'view.deleted', table_id: 5, items: [] }), {
|
||||
redis: redis as unknown as RedisCache,
|
||||
logger: silentLogger(),
|
||||
});
|
||||
expect(redis.calls).toContain('views:table:5');
|
||||
// Sans view_id : pas de pattern views:data:*:* precise.
|
||||
expect(redis.calls.some((p) => /^views:data:\d+/.test(p))).toBe(false);
|
||||
});
|
||||
|
||||
it('view.* invalide que le metadata views, pas les rows', async () => {
|
||||
const redis = new FakeRedis();
|
||||
await handleBaserowEvent(
|
||||
makePayload({ event_type: 'view.updated', table_id: 42, view_id: 100, items: [] }),
|
||||
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||
);
|
||||
expect(redis.calls.every((p) => !p.startsWith('bridge:tables:'))).toBe(true);
|
||||
});
|
||||
|
||||
it('renvoie le total des keys invalidees', async () => {
|
||||
const redis = new FakeRedis();
|
||||
const res = await handleBaserowEvent(
|
||||
makePayload({ event_type: 'rows.updated', items: [{ id: 1 }, { id: 2 }] }),
|
||||
{ redis: redis as unknown as RedisCache, logger: silentLogger() },
|
||||
);
|
||||
// 4 patterns : list, views, row:1, row:2 → 4 keys.
|
||||
expect(res.invalidatedKeys).toBe(4);
|
||||
// R3.1.a : 5 patterns — list:*, views:* (tables keyspace), views:data:* (R3.1.a), row:1, row:2.
|
||||
expect(res.invalidatedKeys).toBe(5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue