Wiki/bridge/tests/repos/baserow-views-repo-r3.test.ts
Corentin JOGUET 95089c460c
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions
feat(bridge): add views endpoints for R3.1.a database-view
Two new endpoints under /api/v1/views:
  GET /api/v1/views/table/:tableId  — list views for a table with Redis
    cache TTL 60s. Returns full view metadata (filters, sortings, groupBys,
    order). Cache invalidated by view.created|updated|deleted webhook events.
  GET /api/v1/views/:viewId/data    — paginated rows of a view applying
    Baserow view filters/sorts via ?view_id= query param. Redis cache TTL 30s
    keyed by (viewId, page, size, search). Requires tableId query param.

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

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

293 lines
11 KiB
TypeScript

/**
* 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 }),
);
});
});