/** * Tests R4.1 timeline-config endpoints. * * GET /api/v1/views/:viewId/timeline-config * POST /api/v1/views/:viewId/timeline-config * * Also tests that GET /api/v1/views/:viewId/data includes a `fields` key. */ import { afterEach, describe, expect, it, vi } from 'vitest'; import type { RedisCache } from '../../src/adapters/redis-cache.js'; 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 type { TimelineConfig } from '../../src/routes/views.js'; import { ADMIN_TOKEN, READ_TOKEN, WRITE_TOKEN, buildTestApp, installTestContainer, resetTestContainer, } from '../helpers/test-app.js'; // --------------------------------------------------------------------------- // Fake repos (minimal subset needed here) // --------------------------------------------------------------------------- class FakeTablesRepo { async list(_db: number) { return []; } async get(_id: number) { throw errors.notFound('Table', _id); } } 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 {} } class FakeViewsRepo { constructor( private viewDataByView: Map = new Map(), ) {} async list(_tableId: number): Promise { return []; } async listByTable(_tableId: number): Promise { return []; } async runGrid(_viewId: number, _tableId: number) { return { items: [], meta: { page: 1, per_page: 50, total: 0, total_pages: 1 } }; } async getViewData(viewId: number, _tableId: number, opts: { page?: number; size?: number } = {}): Promise { 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; } } // --------------------------------------------------------------------------- // Redis stub with controllable storage // --------------------------------------------------------------------------- class FakeRedis { private store: Map = new Map(); async get(key: string): Promise { const raw = this.store.get(key); if (!raw) return null; return JSON.parse(raw) as T; } async set(key: string, value: T, _ttl?: number): Promise { this.store.set(key, JSON.stringify(value)); } async del(key: string | string[]): Promise { const keys = Array.isArray(key) ? key : [key]; for (const k of keys) this.store.delete(k); } async invalidatePattern(_p: string): Promise { return 0; } async checkRateLimit(): Promise { return true; } getClient() { return { xadd: async () => '0-0' }; } } function buildFakeRepos(viewDataByView?: Map): RepoSet { return { tables: new FakeTablesRepo() as unknown as RepoSet['tables'], fields: new FakeFieldsRepo() as unknown as RepoSet['fields'], views: new FakeViewsRepo(viewDataByView) as unknown as RepoSet['views'], rows: new FakeRowsRepo() as unknown as RepoSet['rows'], }; } function bootApp(redis?: FakeRedis, viewDataByView?: Map) { const repos = buildFakeRepos(viewDataByView); const fakeRedis = redis ?? new FakeRedis(); const container = installTestContainer({ repos, redis: fakeRedis as unknown as RedisCache }); return { app: buildTestApp(container), redis: fakeRedis }; } afterEach(resetTestContainer); // --------------------------------------------------------------------------- // GET /api/v1/views/:viewId/timeline-config // --------------------------------------------------------------------------- describe('GET /api/v1/views/:viewId/timeline-config', () => { it('401 sans token', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/1/timeline-config'); expect(res.status).toBe(401); }); it('returns null when no config stored', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/1/timeline-config', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { data: null }; expect(body.data).toBeNull(); }); it('returns stored config', async () => { const redis = new FakeRedis(); const config: TimelineConfig = { startCol: 'Start', endCol: 'End', resourceCol: null, titleCol: 'Name', }; await redis.set('bridge:timeline-config:42', config); const { app } = bootApp(redis); const res = await app.request('/api/v1/views/42/timeline-config', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(200); const body = (await res.json()) as { data: TimelineConfig }; expect(body.data.startCol).toBe('Start'); expect(body.data.titleCol).toBe('Name'); expect(body.data.endCol).toBe('End'); expect(body.data.resourceCol).toBeNull(); }); it('400 si viewId invalide', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/abc/timeline-config', { headers: { Authorization: `Bearer ${READ_TOKEN}` }, }); expect(res.status).toBe(400); }); }); // --------------------------------------------------------------------------- // POST /api/v1/views/:viewId/timeline-config // --------------------------------------------------------------------------- describe('POST /api/v1/views/:viewId/timeline-config', () => { it('401 sans token', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/1/timeline-config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ startCol: 'Start', titleCol: 'Name' }), }); expect(res.status).toBe(401); }); it('403 sans scope write:tables (read-only token)', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/1/timeline-config', { method: 'POST', headers: { Authorization: `Bearer ${READ_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ startCol: 'Start', titleCol: 'Name' }), }); expect(res.status).toBe(403); }); it('400 si startCol absent', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/1/timeline-config', { method: 'POST', headers: { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ titleCol: 'Name' }), }); expect(res.status).toBe(400); }); it('400 si titleCol absent', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/1/timeline-config', { method: 'POST', headers: { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ startCol: 'Start' }), }); expect(res.status).toBe(400); }); it('200 sauvegarde config minimale (sans endCol ni resourceCol)', async () => { const redis = new FakeRedis(); const { app } = bootApp(redis); const res = await app.request('/api/v1/views/10/timeline-config', { method: 'POST', headers: { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ startCol: 'Start', titleCol: 'Name' }), }); expect(res.status).toBe(200); const body = (await res.json()) as { data: TimelineConfig }; expect(body.data.startCol).toBe('Start'); expect(body.data.titleCol).toBe('Name'); expect(body.data.endCol).toBeNull(); expect(body.data.resourceCol).toBeNull(); // Verify persisted in Redis. const stored = await redis.get('bridge:timeline-config:10'); expect(stored?.startCol).toBe('Start'); }); it('200 sauvegarde config complete avec endCol et resourceCol', async () => { const redis = new FakeRedis(); const { app } = bootApp(redis); const payload = { startCol: 'Start', endCol: 'Deadline', resourceCol: 'Team', titleCol: 'Task', }; const res = await app.request('/api/v1/views/20/timeline-config', { method: 'POST', headers: { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }); expect(res.status).toBe(200); const body = (await res.json()) as { data: TimelineConfig }; expect(body.data.endCol).toBe('Deadline'); expect(body.data.resourceCol).toBe('Team'); }); it('admin token peut sauvegarder', async () => { const { app } = bootApp(); const res = await app.request('/api/v1/views/5/timeline-config', { method: 'POST', headers: { Authorization: `Bearer ${ADMIN_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ startCol: 'S', titleCol: 'T' }), }); expect(res.status).toBe(200); }); it('ecrase config existante', async () => { const redis = new FakeRedis(); await redis.set('bridge:timeline-config:7', { startCol: 'Old', titleCol: 'OldTitle', endCol: null, resourceCol: null }); const { app } = bootApp(redis); const res = await app.request('/api/v1/views/7/timeline-config', { method: 'POST', headers: { Authorization: `Bearer ${WRITE_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ startCol: 'New', titleCol: 'NewTitle' }), }); expect(res.status).toBe(200); const stored = await redis.get('bridge:timeline-config:7'); expect(stored?.startCol).toBe('New'); expect(stored?.titleCol).toBe('NewTitle'); }); });