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