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)
|
||||
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)
|
||||
|
||||
**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 :
|
||||
* GET /api/views/table/:tableId — liste les vues d'une table avec cache Redis
|
||||
* GET /api/views/:viewId/data — donnees paginées d'une vue (filters/sort/group
|
||||
* appliques par Baserow via view_id param)
|
||||
* Endpoints :
|
||||
* GET /api/views/table/:tableId — liste vues avec cache Redis
|
||||
* GET /api/views/:viewId/data — donnees paginées d'une vue
|
||||
* 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
|
||||
* generiques (CRUD rows/fields/views sans cache), les routes `views` ici sont
|
||||
* des endpoints specialises R3.1.a avec cache et pagination orientee "database
|
||||
* view" style Notion.
|
||||
* La timeline-config est stockee uniquement en Redis keyed par viewId.
|
||||
* Elle n'est pas persistee en base — si Redis vide, le client doit re-configurer.
|
||||
* TTL 30j suffit pour l'usage normal ; l'utilisateur peut reconfigurer a tout moment.
|
||||
*
|
||||
* Permissions mappees sur les scopes generiques du bridge :
|
||||
* - `database.tables.read` dans `acadenice_permissions[]` est traite comme
|
||||
* `read:tables` par le RBAC DocAdenice avant emission du JWT. On utilise
|
||||
* 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).
|
||||
* Permissions :
|
||||
* - read:tables pour les GET
|
||||
* - write:tables pour le POST timeline-config
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
|
|
@ -26,6 +23,23 @@ import { getContainer } from '../lib/container.js';
|
|||
import { errors } from '../lib/errors.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 }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -121,11 +135,78 @@ viewsRoutes.get('/:viewId/data', requireScope('read:tables'), async (c) => {
|
|||
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({
|
||||
data: result.items.map(serializeRow),
|
||||
total: result.meta.total,
|
||||
page: result.meta.page,
|
||||
size: result.meta.per_page,
|
||||
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