Wiki/bridge/tests/routes/views-r4-timeline.test.ts
Corentin JOGUET 8bda6c5f82 feat(bridge): add timeline-config endpoints and fields in view data — R4.1
- GET /api/v1/views/:viewId/timeline-config: reads config from Redis (TTL 30d)
- POST /api/v1/views/:viewId/timeline-config: saves config (requires write:tables scope)
- GET /api/v1/views/:viewId/data now includes a fields key for column mapping UI
- TimelineConfig type exported for test reuse
- 12 new Vitest tests covering 401/403/400 validation, full CRUD, Redis persistence
- SESSION-RESUME.md updated with R4.1 progress

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 11:27:22 +02:00

300 lines
10 KiB
TypeScript

/**
* 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<Row> {
throw errors.notFound('Row', rowId);
}
async create(tableId: number, fields: Record<string, unknown>): Promise<Row> {
return new Row({ id: 1, tableId, fields });
}
async update(tableId: number, rowId: number, fields: Record<string, unknown>): Promise<Row> {
return new Row({ id: rowId, tableId, fields });
}
async delete(_tableId: number, _rowId: number): Promise<void> {}
}
class FakeViewsRepo {
constructor(
private viewDataByView: Map<number, ViewDataResult> = new Map(),
) {}
async list(_tableId: number): Promise<View[]> { return []; }
async listByTable(_tableId: number): Promise<View[]> { 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<ViewDataResult> {
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<string, string> = new Map();
async get<T>(key: string): Promise<T | null> {
const raw = this.store.get(key);
if (!raw) return null;
return JSON.parse(raw) as T;
}
async set<T>(key: string, value: T, _ttl?: number): Promise<void> {
this.store.set(key, JSON.stringify(value));
}
async del(key: string | string[]): Promise<void> {
const keys = Array.isArray(key) ? key : [key];
for (const k of keys) this.store.delete(k);
}
async invalidatePattern(_p: string): Promise<number> { return 0; }
async checkRateLimit(): Promise<boolean> { return true; }
getClient() { return { xadd: async () => '0-0' }; }
}
function buildFakeRepos(viewDataByView?: Map<number, ViewDataResult>): 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<number, ViewDataResult>) {
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<TimelineConfig>('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<TimelineConfig>('bridge:timeline-config:7');
expect(stored?.startCol).toBe('New');
expect(stored?.titleCol).toBe('NewTitle');
});
});