- 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>
300 lines
10 KiB
TypeScript
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');
|
|
});
|
|
});
|