Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
417 lines
14 KiB
JavaScript
417 lines
14 KiB
JavaScript
/**
|
|
* Widget Library Routes Tests - TDD (ADR-073)
|
|
* Testing /api/v3/widget-library endpoints
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import express from 'express';
|
|
import request from 'supertest';
|
|
import widgetLibraryRoutes from '../widget-library.js';
|
|
import { createWidget } from '../../../services/WidgetService.js';
|
|
import { registerWidget, toggleFavorite, trackUsage } from '../../../services/WidgetLibraryService.js';
|
|
import { dbRun, destroyAdapter, resetAdapter, toBool } from '../../../database/connection.js';
|
|
// Create test app
|
|
function createTestApp() {
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
// Mock authentication middleware - inject user into req
|
|
app.use((req, res, next) => {
|
|
req.user = { id: req.headers['x-test-user-id'] ? parseInt(req.headers['x-test-user-id']) : 1 };
|
|
next();
|
|
});
|
|
|
|
app.use('/api/v3', widgetLibraryRoutes);
|
|
return app;
|
|
}
|
|
|
|
// Helper functions
|
|
async function createTestUser(suffix = '') {
|
|
const uniqueEmail = `test-wl-route-${Date.now()}${suffix}@hltrn.cc`;
|
|
const result = await dbRun(
|
|
'INSERT INTO users (email, password_hash, name, encryption_key_encrypted, email_verified) VALUES (?, ?, ?, ?, ?)',
|
|
[uniqueEmail, 'hash', 'Test User', 'encrypted_key', 1]
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
async function createTestSpace(ownerId, name = 'Test Space') {
|
|
const result = await dbRun(
|
|
'INSERT INTO spaces (owner_id, name, type) VALUES (?, ?, ?)',
|
|
[ownerId, name, 'business']
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
async function createTestProject(ownerId, spaceId) {
|
|
const result = await dbRun(
|
|
'INSERT INTO projects (owner_id, space_id, name, type) VALUES (?, ?, ?, ?)',
|
|
[ownerId, spaceId, 'Test Project', 'business']
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
async function createTestDashboard(projectId) {
|
|
const result = await dbRun(
|
|
'INSERT INTO dashboards (project_id, name, is_default) VALUES (?, ?, ?)',
|
|
[projectId, 'Test Dashboard', toBool(true)]
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
async function createTestWidget(dashboardId, options = {}) {
|
|
const { title = 'Test Widget', presetName = 'table_view' } = options;
|
|
return await createWidget({
|
|
dashboard_id: dashboardId,
|
|
widget_type: 'preset',
|
|
preset_name: presetName,
|
|
title,
|
|
config: {},
|
|
position: { x: 0, y: 0, w: 6, h: 4 }
|
|
});
|
|
}
|
|
|
|
describe('Widget Library Routes - ADR-073', () => {
|
|
let app;
|
|
let userId;
|
|
let spaceId;
|
|
let projectId;
|
|
let dashboardId;
|
|
|
|
beforeEach(async () => {
|
|
process.env.TEST_MODE = 'true';
|
|
process.env.SKIP_DEV_USER = 'true';
|
|
await resetAdapter();
|
|
|
|
app = createTestApp();
|
|
userId = await createTestUser();
|
|
spaceId = await createTestSpace(userId);
|
|
projectId = await createTestProject(userId, spaceId);
|
|
dashboardId = await createTestDashboard(projectId);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await destroyAdapter();
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /api/v3/widget-library
|
|
// ============================================================
|
|
describe('GET /api/v3/widget-library', () => {
|
|
test('should return library widgets for space', async () => {
|
|
const widget = await createTestWidget(dashboardId, { title: 'Sales Board' });
|
|
await registerWidget(widget.id, spaceId);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library')
|
|
.query({ space_id: spaceId })
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data.items).toHaveLength(1);
|
|
expect(res.body.data.items[0].title).toBe('Sales Board');
|
|
expect(res.body.data.total).toBe(1);
|
|
expect(res.body.data.categories).toBeDefined();
|
|
});
|
|
|
|
test('should require space_id query parameter', async () => {
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library')
|
|
.set('x-test-user-id', userId)
|
|
.expect(400);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
expect(res.body.error.message).toContain('space_id');
|
|
});
|
|
|
|
test('should filter by category', async () => {
|
|
const widget1 = await createTestWidget(dashboardId, { title: 'Widget 1' });
|
|
const widget2 = await createTestWidget(dashboardId, { title: 'Widget 2' });
|
|
await registerWidget(widget1.id, spaceId);
|
|
await registerWidget(widget2.id, spaceId);
|
|
await toggleFavorite(userId, widget1.id);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library')
|
|
.query({ space_id: spaceId, category: 'favorites' })
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.data.items).toHaveLength(1);
|
|
expect(res.body.data.items[0].widget_id).toBe(widget1.id);
|
|
});
|
|
|
|
test('should search by title', async () => {
|
|
const widget1 = await createTestWidget(dashboardId, { title: 'Sales Pipeline' });
|
|
const widget2 = await createTestWidget(dashboardId, { title: 'Tasks Board' });
|
|
await registerWidget(widget1.id, spaceId);
|
|
await registerWidget(widget2.id, spaceId);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library')
|
|
.query({ space_id: spaceId, search: 'Sales' })
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.data.items).toHaveLength(1);
|
|
expect(res.body.data.items[0].title).toBe('Sales Pipeline');
|
|
});
|
|
|
|
test('should support pagination', async () => {
|
|
// Create 5 widgets
|
|
for (let i = 0; i < 5; i++) {
|
|
const widget = await createTestWidget(dashboardId, { title: `Widget ${i}` });
|
|
await registerWidget(widget.id, spaceId);
|
|
}
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library')
|
|
.query({ space_id: spaceId, limit: 2, offset: 0 })
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.data.items).toHaveLength(2);
|
|
expect(res.body.data.total).toBe(5);
|
|
});
|
|
|
|
test('should include public widgets when include_public=true', async () => {
|
|
// Create another space with public widget
|
|
const otherUserId = await createTestUser('-other');
|
|
const otherSpaceId = await createTestSpace(otherUserId, 'Other Space');
|
|
const otherProjectId = await createTestProject(otherUserId, otherSpaceId);
|
|
const otherDashboardId = await createTestDashboard(otherProjectId);
|
|
const publicWidget = await createTestWidget(otherDashboardId, { title: 'Public Widget' });
|
|
await registerWidget(publicWidget.id, otherSpaceId, { is_public: true });
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library')
|
|
.query({ space_id: spaceId, include_public: true })
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
const widgetIds = res.body.data.items.map(w => w.widget_id);
|
|
expect(widgetIds).toContain(publicWidget.id);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /api/v3/widget-library/favorites
|
|
// ============================================================
|
|
describe('GET /api/v3/widget-library/favorites', () => {
|
|
test('should return user favorites', async () => {
|
|
const widget = await createTestWidget(dashboardId, { title: 'Favorite Widget' });
|
|
await registerWidget(widget.id, spaceId);
|
|
await toggleFavorite(userId, widget.id);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library/favorites')
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data).toHaveLength(1);
|
|
expect(res.body.data[0].title).toBe('Favorite Widget');
|
|
});
|
|
|
|
test('should return empty array when no favorites', async () => {
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library/favorites')
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.data).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /api/v3/widget-library/recent
|
|
// ============================================================
|
|
describe('GET /api/v3/widget-library/recent', () => {
|
|
test('should return recently used widgets', async () => {
|
|
const widget = await createTestWidget(dashboardId, { title: 'Recent Widget' });
|
|
await registerWidget(widget.id, spaceId);
|
|
await trackUsage(userId, widget.id);
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library/recent')
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data).toHaveLength(1);
|
|
expect(res.body.data[0].title).toBe('Recent Widget');
|
|
});
|
|
|
|
test('should respect limit parameter', async () => {
|
|
for (let i = 0; i < 5; i++) {
|
|
const widget = await createTestWidget(dashboardId, { title: `Widget ${i}` });
|
|
await registerWidget(widget.id, spaceId);
|
|
await trackUsage(userId, widget.id);
|
|
}
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/widget-library/recent')
|
|
.query({ limit: 3 })
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.data).toHaveLength(3);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// POST /api/v3/widget-library/:widgetId/favorite
|
|
// ============================================================
|
|
describe('POST /api/v3/widget-library/:widgetId/favorite', () => {
|
|
test('should toggle favorite on', async () => {
|
|
const widget = await createTestWidget(dashboardId);
|
|
await registerWidget(widget.id, spaceId);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/v3/widget-library/${widget.id}/favorite`)
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data.is_favorite).toBe(true);
|
|
expect(res.body.data.widget_id).toBe(widget.id);
|
|
});
|
|
|
|
test('should toggle favorite off', async () => {
|
|
const widget = await createTestWidget(dashboardId);
|
|
await registerWidget(widget.id, spaceId);
|
|
await toggleFavorite(userId, widget.id); // Add first
|
|
|
|
const res = await request(app)
|
|
.post(`/api/v3/widget-library/${widget.id}/favorite`)
|
|
.set('x-test-user-id', userId)
|
|
.expect(200);
|
|
|
|
expect(res.body.data.is_favorite).toBe(false);
|
|
});
|
|
|
|
test('should return 404 for non-existent widget', async () => {
|
|
const res = await request(app)
|
|
.post('/api/v3/widget-library/99999/favorite')
|
|
.set('x-test-user-id', userId)
|
|
.expect(404);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// POST /api/v3/dashboards/:dashboardId/widgets/from-library
|
|
// ============================================================
|
|
describe('POST /api/v3/dashboards/:dashboardId/widgets/from-library', () => {
|
|
test('should add widget as reference', async () => {
|
|
const sourceWidget = await createTestWidget(dashboardId, { title: 'Source Widget', presetName: 'kanban_board' });
|
|
await registerWidget(sourceWidget.id, spaceId);
|
|
|
|
const targetDashboardId = (await dbRun(
|
|
'INSERT INTO dashboards (project_id, name, is_default) VALUES (?, ?, ?)',
|
|
[projectId, 'Target Dashboard', 0]
|
|
)).lastInsertRowid;
|
|
|
|
const res = await request(app)
|
|
.post(`/api/v3/dashboards/${targetDashboardId}/widgets/from-library`)
|
|
.set('x-test-user-id', userId)
|
|
.send({
|
|
source_widget_id: sourceWidget.id,
|
|
mode: 'reference',
|
|
position: { x: 0, y: 0, w: 6, h: 4 }
|
|
})
|
|
.expect(201);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data.widget).toBeDefined();
|
|
expect(res.body.data.widget.source_widget_id).toBe(sourceWidget.id);
|
|
expect(res.body.data.mode_used).toBe('reference');
|
|
});
|
|
|
|
test('should add widget as copy', async () => {
|
|
const sourceWidget = await createTestWidget(dashboardId, { title: 'Source Widget' });
|
|
await registerWidget(sourceWidget.id, spaceId);
|
|
|
|
const targetDashboardId = (await dbRun(
|
|
'INSERT INTO dashboards (project_id, name, is_default) VALUES (?, ?, ?)',
|
|
[projectId, 'Target Dashboard 2', 0]
|
|
)).lastInsertRowid;
|
|
|
|
const res = await request(app)
|
|
.post(`/api/v3/dashboards/${targetDashboardId}/widgets/from-library`)
|
|
.set('x-test-user-id', userId)
|
|
.send({
|
|
source_widget_id: sourceWidget.id,
|
|
mode: 'copy',
|
|
position: { x: 0, y: 0, w: 6, h: 4 }
|
|
})
|
|
.expect(201);
|
|
|
|
expect(res.body.data.widget.source_widget_id).toBeNull();
|
|
expect(res.body.data.mode_used).toBe('copy');
|
|
});
|
|
|
|
test('should require source_widget_id', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/v3/dashboards/${dashboardId}/widgets/from-library`)
|
|
.set('x-test-user-id', userId)
|
|
.send({
|
|
mode: 'reference',
|
|
position: { x: 0, y: 0, w: 6, h: 4 }
|
|
})
|
|
.expect(400);
|
|
|
|
expect(res.body.error.message).toContain('source_widget_id');
|
|
});
|
|
|
|
test('should require valid mode', async () => {
|
|
const widget = await createTestWidget(dashboardId);
|
|
await registerWidget(widget.id, spaceId);
|
|
|
|
const res = await request(app)
|
|
.post(`/api/v3/dashboards/${dashboardId}/widgets/from-library`)
|
|
.set('x-test-user-id', userId)
|
|
.send({
|
|
source_widget_id: widget.id,
|
|
mode: 'invalid',
|
|
position: { x: 0, y: 0, w: 6, h: 4 }
|
|
})
|
|
.expect(400);
|
|
|
|
expect(res.body.error.message).toContain('Invalid mode');
|
|
});
|
|
|
|
test('should return 404 for non-existent dashboard', async () => {
|
|
const widget = await createTestWidget(dashboardId);
|
|
await registerWidget(widget.id, spaceId);
|
|
|
|
const res = await request(app)
|
|
.post('/api/v3/dashboards/99999/widgets/from-library')
|
|
.set('x-test-user-id', userId)
|
|
.send({
|
|
source_widget_id: widget.id,
|
|
mode: 'reference',
|
|
position: { x: 0, y: 0, w: 6, h: 4 }
|
|
})
|
|
.expect(404);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
|
|
test('should return 404 for non-existent source widget', async () => {
|
|
const res = await request(app)
|
|
.post(`/api/v3/dashboards/${dashboardId}/widgets/from-library`)
|
|
.set('x-test-user-id', userId)
|
|
.send({
|
|
source_widget_id: 99999,
|
|
mode: 'reference',
|
|
position: { x: 0, y: 0, w: 6, h: 4 }
|
|
})
|
|
.expect(404);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
});
|
|
});
|