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>
293 lines
11 KiB
TypeScript
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 }),
|
|
);
|
|
});
|
|
});
|