From 95089c460c1a0bfc8d17f0bdb1e225e0f2c693e7 Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Thu, 7 May 2026 23:24:10 +0200 Subject: [PATCH] feat(bridge): add views endpoints for R3.1.a database-view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- bridge/src/adapters/baserow-client.ts | 3 + bridge/src/domain/view.ts | 37 +- bridge/src/index.ts | 3 + bridge/src/repos/baserow-views-repo.ts | 232 +++++++++- bridge/src/routes/views.ts | 131 ++++++ bridge/src/webhooks/baserow-handler.ts | 45 +- bridge/src/webhooks/types.ts | 12 +- bridge/tests/helpers/test-app.ts | 4 + .../tests/repos/baserow-views-repo-r3.test.ts | 293 ++++++++++++ bridge/tests/routes/views.test.ts | 431 ++++++++++++++++++ bridge/tests/webhooks/baserow-handler.test.ts | 57 ++- 11 files changed, 1224 insertions(+), 24 deletions(-) create mode 100644 bridge/src/routes/views.ts create mode 100644 bridge/tests/repos/baserow-views-repo-r3.test.ts create mode 100644 bridge/tests/routes/views.test.ts diff --git a/bridge/src/adapters/baserow-client.ts b/bridge/src/adapters/baserow-client.ts index 5336162..e497fe7 100644 --- a/bridge/src/adapters/baserow-client.ts +++ b/bridge/src/adapters/baserow-client.ts @@ -27,6 +27,8 @@ export interface BaserowListOptions { filter?: Record; 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); diff --git a/bridge/src/domain/view.ts b/bridge/src/domain/view.ts index 2479341..8cd1c99 100644 --- a/bridge/src/domain/view.ts +++ b/bridge/src/domain/view.ts @@ -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 ?? []; } } diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 3d813cf..0f6f6a3 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -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> { @@ -79,6 +80,8 @@ export async function buildApp(): Promise> { 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)); diff --git a/bridge/src/repos/baserow-views-repo.ts b/bridge/src/repos/baserow-views-repo.ts index 4d8ed77..25e1504 100644 --- a/bridge/src/repos/baserow-views-repo.ts +++ b/bridge/src/repos/baserow-views-repo.ts @@ -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).id === 'number' + ) { + const raw = f as Record; + 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).id === 'number' + ) { + const raw = s as Record; + 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).id === 'number' + ) { + const raw = g as Record; + 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 { 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 { + const cacheKey = buildViewsListCacheKey(tableId); + + if (redis) { + const cached = await redis.get(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 { + 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(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[0]; diff --git a/bridge/src/routes/views.ts b/bridge/src/routes/views.ts new file mode 100644 index 0000000..cb9461d --- /dev/null +++ b/bridge/src/routes/views.ts @@ -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, + }); +}); diff --git a/bridge/src/webhooks/baserow-handler.ts b/bridge/src/webhooks/baserow-handler.ts index cdc0db1..30097d4 100644 --- a/bridge/src/webhooks/baserow-handler.ts +++ b/bridge/src/webhooks/baserow-handler.ts @@ -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:` : 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, diff --git a/bridge/src/webhooks/types.ts b/bridge/src/webhooks/types.ts index 01ccc79..9820e31 100644 --- a/bridge/src/webhooks/types.ts +++ b/bridge/src/webhooks/types.ts @@ -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; 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()) diff --git a/bridge/tests/helpers/test-app.ts b/bridge/tests/helpers/test-app.ts index cbc2558..ea6f7bd 100644 --- a/bridge/tests/helpers/test-app.ts +++ b/bridge/tests/helpers/test-app.ts @@ -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; diff --git a/bridge/tests/repos/baserow-views-repo-r3.test.ts b/bridge/tests/repos/baserow-views-repo-r3.test.ts new file mode 100644 index 0000000..2751f20 --- /dev/null +++ b/bridge/tests/repos/baserow-views-repo-r3.test.ts @@ -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 }), + ); + }); +}); diff --git a/bridge/tests/routes/views.test.ts b/bridge/tests/routes/views.test.ts new file mode 100644 index 0000000..2d7277a --- /dev/null +++ b/bridge/tests/routes/views.test.ts @@ -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 { + 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); + }); +}); diff --git a/bridge/tests/webhooks/baserow-handler.test.ts b/bridge/tests/webhooks/baserow-handler.test.ts index 236474a..e5dd915 100644 --- a/bridge/tests/webhooks/baserow-handler.test.ts +++ b/bridge/tests/webhooks/baserow-handler.test.ts @@ -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::*', 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: + 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); }); });