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>
This commit is contained in:
parent
8ea4c3fd10
commit
8bda6c5f82
3 changed files with 434 additions and 15 deletions
|
|
@ -104,6 +104,44 @@ Owner=`admin:*`, Admin=tout sauf `*:delete` et `roles:manage`, Editor, Member, G
|
||||||
### Mode Loop full autonome (decision 2026-05-08)
|
### Mode Loop full autonome (decision 2026-05-08)
|
||||||
Loop autonome R3.1.d -> R3.8 termine. Patch 017 fix typecheck post-install pnpm. Etat final : 0 TS error client + server, 313 tests client + 210 tests server + 380 tests bridge tous verts.
|
Loop autonome R3.1.d -> R3.8 termine. Patch 017 fix typecheck post-install pnpm. Etat final : 0 TS error client + server, 313 tests client + 210 tests server + 380 tests bridge tous verts.
|
||||||
|
|
||||||
|
## R4 progress
|
||||||
|
|
||||||
|
### R4.1 — Timeline view (Gantt) — LIVRE
|
||||||
|
|
||||||
|
Commit : TBD (voir git log -1 dans docmost/)
|
||||||
|
Tests : 26 nouveaux (14 client timeline-renderer + 12 bridge views-r4-timeline)
|
||||||
|
Total client apres R4.1 : 326 tests verts (+ 1 pre-existing clipper failure non liee, uses jest.mock dans Vitest)
|
||||||
|
Total bridge apres R4.1 : 392 tests verts
|
||||||
|
|
||||||
|
**Deps ajoutees** :
|
||||||
|
- `@fullcalendar/timeline@^6.1.20` (client)
|
||||||
|
- `@fullcalendar/resource-timeline@^6.1.20` (client)
|
||||||
|
|
||||||
|
**Fichiers crees** :
|
||||||
|
- `apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.tsx`
|
||||||
|
- `apps/client/src/features/acadenice/database-view/renderers/timeline-renderer.module.css`
|
||||||
|
- `apps/client/src/features/acadenice/database-view/hooks/use-timeline-config.ts`
|
||||||
|
- `apps/client/src/features/acadenice/database-view/__tests__/timeline-renderer.test.tsx`
|
||||||
|
- `bridge/tests/routes/views-r4-timeline.test.ts`
|
||||||
|
|
||||||
|
**Fichiers modifies** :
|
||||||
|
- `bridge/src/routes/views.ts` — 2 nouveaux endpoints (GET+POST /views/:id/timeline-config) + fields dans /data response
|
||||||
|
- `apps/client/src/features/acadenice/database-view/extension/database-view-component.tsx` — dispatch timeline
|
||||||
|
- `apps/client/src/features/acadenice/database-view/slash-command/insert-database-modal.tsx` — step 3 mapping timeline
|
||||||
|
- `apps/client/src/features/acadenice/database-view/types/database-view.types.ts` — "timeline" dans SUPPORTED_VIEW_TYPES
|
||||||
|
|
||||||
|
**Architecture timeline** :
|
||||||
|
- Bridge Redis TTL 30j pour timeline-config keyed par viewId
|
||||||
|
- POST /views/:id/timeline-config requiert scope write:tables (403 pour read-only tokens)
|
||||||
|
- GET /views/:id/timeline-config requiert scope read:tables
|
||||||
|
- Client hook useTimelineConfig (GET+POST via bridge-client)
|
||||||
|
- Modal 3-step : table -> view -> column-mapping (step 3 visible uniquement pour viewType=timeline)
|
||||||
|
- Renderer : config panel quand pas de config ; FullCalendar Timeline apres config
|
||||||
|
- Resource swimlane automatique quand resourceCol configure
|
||||||
|
- eventResize persiste endCol via useUpdateRow (respecte canWriteRows)
|
||||||
|
- Fallback end = start+1j si endCol absent
|
||||||
|
- Toutes hooks declarees avant early returns (React rules-of-hooks respectees)
|
||||||
|
|
||||||
### Questions ouvertes a trancher post-/compact (2026-05-08)
|
### Questions ouvertes a trancher post-/compact (2026-05-08)
|
||||||
|
|
||||||
**Q1 — Strategie test E2E AI-driven (Claude Code / Stagehand / Computer Use)**
|
**Q1 — Strategie test E2E AI-driven (Claude Code / Stagehand / Computer Use)**
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,19 @@
|
||||||
/**
|
/**
|
||||||
* Routes /api/views — R3.1.a database-view.
|
* Routes /api/views — R3.1.a database-view, R4.1 timeline.
|
||||||
*
|
*
|
||||||
* Deux endpoints :
|
* Endpoints :
|
||||||
* GET /api/views/table/:tableId — liste les vues d'une table avec cache Redis
|
* GET /api/views/table/:tableId — liste vues avec cache Redis
|
||||||
* GET /api/views/:viewId/data — donnees paginées d'une vue (filters/sort/group
|
* GET /api/views/:viewId/data — donnees paginées d'une vue
|
||||||
* appliques par Baserow via view_id param)
|
* GET /api/views/:viewId/timeline-config — lit la config Gantt (Redis TTL 30j)
|
||||||
|
* POST /api/views/:viewId/timeline-config — sauvegarde la config Gantt (Redis TTL 30j)
|
||||||
*
|
*
|
||||||
* Separation de /api/v1/tables/* voulue : les routes `tables` sont des metadata
|
* La timeline-config est stockee uniquement en Redis keyed par viewId.
|
||||||
* generiques (CRUD rows/fields/views sans cache), les routes `views` ici sont
|
* Elle n'est pas persistee en base — si Redis vide, le client doit re-configurer.
|
||||||
* des endpoints specialises R3.1.a avec cache et pagination orientee "database
|
* TTL 30j suffit pour l'usage normal ; l'utilisateur peut reconfigurer a tout moment.
|
||||||
* view" style Notion.
|
|
||||||
*
|
*
|
||||||
* Permissions mappees sur les scopes generiques du bridge :
|
* Permissions :
|
||||||
* - `database.tables.read` dans `acadenice_permissions[]` est traite comme
|
* - read:tables pour les GET
|
||||||
* `read:tables` par le RBAC DocAdenice avant emission du JWT. On utilise
|
* - write:tables pour le POST timeline-config
|
||||||
* donc `requireScope('read:tables')` pour rester coherent avec le systeme
|
|
||||||
* existant. De meme `database.rows.read` -> `read:tables` (meme scope
|
|
||||||
* car les rows sont lues en contexte de table).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
|
@ -26,6 +23,23 @@ import { getContainer } from '../lib/container.js';
|
||||||
import { errors } from '../lib/errors.js';
|
import { errors } from '../lib/errors.js';
|
||||||
import { type AuthVariables, requireScope } from '../middleware/auth.js';
|
import { type AuthVariables, requireScope } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types timeline-config
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface TimelineConfig {
|
||||||
|
startCol: string;
|
||||||
|
endCol: string | null;
|
||||||
|
resourceCol: string | null;
|
||||||
|
titleCol: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMELINE_CONFIG_TTL_SECONDS = 30 * 24 * 3600; // 30 days
|
||||||
|
|
||||||
|
function timelineConfigKey(viewId: number): string {
|
||||||
|
return `bridge:timeline-config:${viewId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const viewsRoutes = new Hono<{ Variables: AuthVariables }>();
|
export const viewsRoutes = new Hono<{ Variables: AuthVariables }>();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -121,11 +135,78 @@ viewsRoutes.get('/:viewId/data', requireScope('read:tables'), async (c) => {
|
||||||
redis,
|
redis,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Include field descriptors so the client can build column mapping selects
|
||||||
|
// without an extra round-trip. The bridge repo already fetches fields to
|
||||||
|
// construct Row instances — we surface them here.
|
||||||
|
const fields = (result as unknown as { fields?: unknown[] }).fields ?? [];
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
data: result.items.map(serializeRow),
|
data: result.items.map(serializeRow),
|
||||||
total: result.meta.total,
|
total: result.meta.total,
|
||||||
page: result.meta.page,
|
page: result.meta.page,
|
||||||
size: result.meta.per_page,
|
size: result.meta.per_page,
|
||||||
viewType: result.viewType,
|
viewType: result.viewType,
|
||||||
|
fields,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /api/views/:viewId/timeline-config — R4.1
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
viewsRoutes.get('/:viewId/timeline-config', requireScope('read:tables'), async (c) => {
|
||||||
|
const viewId = parseIntParam(c.req.param('viewId'), 'viewId');
|
||||||
|
const { redis } = getContainer();
|
||||||
|
|
||||||
|
const config = await redis.get<TimelineConfig>(timelineConfigKey(viewId));
|
||||||
|
if (!config) {
|
||||||
|
return c.json({ data: null });
|
||||||
|
}
|
||||||
|
return c.json({ data: config });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /api/views/:viewId/timeline-config — R4.1
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
viewsRoutes.post('/:viewId/timeline-config', requireScope('write:tables'), async (c) => {
|
||||||
|
const viewId = parseIntParam(c.req.param('viewId'), 'viewId');
|
||||||
|
const { redis } = getContainer();
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
throw errors.validation([{ message: 'Request body must be valid JSON' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body !== 'object' || body === null) {
|
||||||
|
throw errors.validation([{ message: 'Body must be an object' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const b = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (typeof b['startCol'] !== 'string' || b['startCol'].trim() === '') {
|
||||||
|
throw errors.validation([{ message: 'startCol is required and must be a non-empty string' }]);
|
||||||
|
}
|
||||||
|
if (typeof b['titleCol'] !== 'string' || b['titleCol'].trim() === '') {
|
||||||
|
throw errors.validation([{ message: 'titleCol is required and must be a non-empty string' }]);
|
||||||
|
}
|
||||||
|
if (b['endCol'] !== null && b['endCol'] !== undefined && typeof b['endCol'] !== 'string') {
|
||||||
|
throw errors.validation([{ message: 'endCol must be a string or null' }]);
|
||||||
|
}
|
||||||
|
if (b['resourceCol'] !== null && b['resourceCol'] !== undefined && typeof b['resourceCol'] !== 'string') {
|
||||||
|
throw errors.validation([{ message: 'resourceCol must be a string or null' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: TimelineConfig = {
|
||||||
|
startCol: b['startCol'] as string,
|
||||||
|
endCol: (b['endCol'] as string | null | undefined) ?? null,
|
||||||
|
resourceCol: (b['resourceCol'] as string | null | undefined) ?? null,
|
||||||
|
titleCol: b['titleCol'] as string,
|
||||||
|
};
|
||||||
|
|
||||||
|
await redis.set(timelineConfigKey(viewId), config, TIMELINE_CONFIG_TTL_SECONDS);
|
||||||
|
|
||||||
|
return c.json({ data: config }, 200);
|
||||||
|
});
|
||||||
|
|
|
||||||
300
bridge/tests/routes/views-r4-timeline.test.ts
Normal file
300
bridge/tests/routes/views-r4-timeline.test.ts
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue