Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
671 lines
25 KiB
JavaScript
671 lines
25 KiB
JavaScript
/**
|
|
* Tables API Routes Tests (v3) - ADR-064 Phase 2, Task 6
|
|
* Testing REST API endpoints for table CRUD operations
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
import request from 'supertest';
|
|
import express from 'express';
|
|
import tablesRoutes from '../tables.js';
|
|
import { dbAll, dbGet, dbRun, destroyAdapter, resetAdapter } from '../../../database/connection.js';
|
|
const app = express();
|
|
app.use(express.json());
|
|
|
|
let mockUserId = 1;
|
|
app.use((req, _res, next) => {
|
|
req.user = { id: mockUserId, role: 'owner' };
|
|
next();
|
|
});
|
|
|
|
app.use('/api/v3', tablesRoutes);
|
|
|
|
async function createTestUser() {
|
|
const email = `test-tables-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@hltrn.cc`;
|
|
const result = await dbRun(
|
|
'INSERT INTO users (email, password_hash, name, encryption_key_encrypted, email_verified) VALUES (?, ?, ?, ?, ?)',
|
|
[email, 'hash', 'Test User', 'encrypted_key', 1]
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
async function createTestSpace(ownerId) {
|
|
const result = await dbRun(
|
|
'INSERT INTO spaces (owner_id, name, type) VALUES (?, ?, ?)',
|
|
[ownerId, 'Test Space', '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 createTestTable(projectId, name = 'Test Table') {
|
|
const result = await dbRun(
|
|
'INSERT INTO universal_tables (project_id, name, description) VALUES (?, ?, ?)',
|
|
[projectId, name.toLowerCase().replace(/\s+/g, '_'), name]
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
describe('Tables API Routes (v3) - ADR-064', () => {
|
|
let userId, spaceId, projectId;
|
|
|
|
beforeEach(async () => {
|
|
process.env.TEST_MODE = 'true';
|
|
process.env.SKIP_DEV_USER = 'true';
|
|
await resetAdapter();
|
|
|
|
// Add columns that migrations skip (workspace_tables → universal_tables rename issue)
|
|
const missingCols = [
|
|
'display_name TEXT',
|
|
'data_source_id INTEGER',
|
|
'source_table_name TEXT',
|
|
'source_id_column TEXT',
|
|
'sync_enabled INTEGER DEFAULT 0',
|
|
'sync_interval_minutes INTEGER',
|
|
'last_sync_at TEXT',
|
|
'parent_table_id INTEGER',
|
|
'show_in_nav INTEGER DEFAULT 1',
|
|
'config TEXT'
|
|
];
|
|
for (const col of missingCols) {
|
|
try {
|
|
await dbRun(`ALTER TABLE universal_tables ADD COLUMN ${col}`);
|
|
} catch {
|
|
// Column may already exist
|
|
}
|
|
}
|
|
|
|
userId = await createTestUser();
|
|
mockUserId = userId;
|
|
spaceId = await createTestSpace(userId);
|
|
projectId = await createTestProject(userId, spaceId);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await destroyAdapter();
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /api/v3/tables
|
|
// ============================================================
|
|
describe('GET /api/v3/tables', () => {
|
|
test('should return all tables for user', async () => {
|
|
await createTestTable(projectId, 'Table A');
|
|
await createTestTable(projectId, 'Table B');
|
|
|
|
const res = await request(app)
|
|
.get('/api/v3/tables')
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(Array.isArray(res.body.data)).toBe(true);
|
|
expect(res.body.data.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /api/v3/tables/:tableId
|
|
// ============================================================
|
|
describe('GET /api/v3/tables/:tableId', () => {
|
|
test('should return table by id with columns', async () => {
|
|
const tableId = await createTestTable(projectId, 'My Table');
|
|
|
|
const res = await request(app)
|
|
.get(`/api/v3/tables/${tableId}`)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data.id).toBe(tableId);
|
|
});
|
|
|
|
test('should return 404 for non-existent table', async () => {
|
|
const res = await request(app)
|
|
.get('/api/v3/tables/99999')
|
|
.expect(404);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// POST /api/v3/tables
|
|
// ============================================================
|
|
describe('POST /api/v3/tables', () => {
|
|
test('should create table with valid data', async () => {
|
|
const res = await request(app)
|
|
.post('/api/v3/tables')
|
|
.send({ project_id: projectId, name: 'new_table' })
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data.table).toBeDefined();
|
|
expect(res.body.data.table.name).toBe('new_table');
|
|
});
|
|
|
|
test('should reject missing project_id', async () => {
|
|
const res = await request(app)
|
|
.post('/api/v3/tables')
|
|
.send({ name: 'no_project' })
|
|
.expect(400);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
|
|
test('should reject missing name', async () => {
|
|
const res = await request(app)
|
|
.post('/api/v3/tables')
|
|
.send({ project_id: projectId })
|
|
.expect(400);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// PATCH /api/v3/tables/:tableId (display settings)
|
|
// ============================================================
|
|
describe('PATCH /api/v3/tables/:tableId', () => {
|
|
test('should update table display name', async () => {
|
|
const tableId = await createTestTable(projectId, 'old_display');
|
|
|
|
const res = await request(app)
|
|
.patch(`/api/v3/tables/${tableId}`)
|
|
.send({ displayName: 'New Display Name' })
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
const updated = await dbGet('SELECT * FROM universal_tables WHERE id = ?', [tableId]);
|
|
expect(updated.display_name).toBe('New Display Name');
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// DELETE /api/v3/tables/:tableId
|
|
// ============================================================
|
|
describe('DELETE /api/v3/tables/:tableId', () => {
|
|
test('should delete table owned by user', async () => {
|
|
const tableId = await createTestTable(projectId, 'delete_me');
|
|
|
|
const res = await request(app)
|
|
.delete(`/api/v3/tables/${tableId}`)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
const table = await dbGet('SELECT * FROM universal_tables WHERE id = ?', [tableId]);
|
|
expect(table).toBeNull();
|
|
});
|
|
|
|
test('should return 404 for non-existent table', async () => {
|
|
const res = await request(app)
|
|
.delete('/api/v3/tables/99999')
|
|
.expect(404);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /api/v3/tables/:tableId/rows/:rowId
|
|
// ============================================================
|
|
describe('GET /api/v3/tables/:tableId/rows/:rowId', () => {
|
|
let tableId, col1Id, col2Id;
|
|
|
|
beforeEach(async () => {
|
|
tableId = await createTestTable(projectId, 'Row Test Table');
|
|
// Create columns
|
|
const col1 = await dbRun(
|
|
`INSERT INTO table_columns (table_id, column_name, display_name, type, config, is_required, order_index, is_visible)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[tableId, 'name', 'Name', 'text', '{}', 0, 0, 1]
|
|
);
|
|
col1Id = col1.lastInsertRowid;
|
|
const col2 = await dbRun(
|
|
`INSERT INTO table_columns (table_id, column_name, display_name, type, config, is_required, order_index, is_visible)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[tableId, 'email', 'Email', 'text', '{}', 0, 1, 1]
|
|
);
|
|
col2Id = col2.lastInsertRowid;
|
|
});
|
|
|
|
test('should return a single row by numeric ID', async () => {
|
|
const rowData = { [col1Id]: 'John Doe', [col2Id]: 'john@test.com' };
|
|
const baseId = `row_test_${Date.now()}`;
|
|
const insertResult = await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
|
|
[tableId, baseId, JSON.stringify(rowData), userId]
|
|
);
|
|
const rowId = insertResult.lastInsertRowid;
|
|
|
|
const res = await request(app)
|
|
.get(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data.row).toBeDefined();
|
|
expect(res.body.data.row.id).toBe(rowId);
|
|
expect(res.body.data.row.table_id).toBe(tableId);
|
|
expect(res.body.data.row.data[String(col1Id)]).toBe('John Doe');
|
|
expect(res.body.data.row.data[String(col2Id)]).toBe('john@test.com');
|
|
expect(res.body.data.row.created_at).toBeDefined();
|
|
expect(res.body.data.row.updated_at).toBeDefined();
|
|
});
|
|
|
|
test('should return a single row by base_id', async () => {
|
|
const rowData = { [col1Id]: 'Jane Doe', [col2Id]: 'jane@test.com' };
|
|
const baseId = `row_base_test_${Date.now()}`;
|
|
await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
|
|
[tableId, baseId, JSON.stringify(rowData), userId]
|
|
);
|
|
|
|
const res = await request(app)
|
|
.get(`/api/v3/tables/${tableId}/rows/${baseId}`)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data.row).toBeDefined();
|
|
expect(res.body.data.row.base_id).toBe(baseId);
|
|
expect(res.body.data.row.data[String(col1Id)]).toBe('Jane Doe');
|
|
});
|
|
|
|
test('should include column metadata in response', async () => {
|
|
const rowData = { [col1Id]: 'Alice', [col2Id]: 'alice@test.com' };
|
|
const baseId = `row_cols_${Date.now()}`;
|
|
const insertResult = await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
|
|
[tableId, baseId, JSON.stringify(rowData), userId]
|
|
);
|
|
const rowId = insertResult.lastInsertRowid;
|
|
|
|
const res = await request(app)
|
|
.get(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(res.body.data.columns).toBeDefined();
|
|
expect(Array.isArray(res.body.data.columns)).toBe(true);
|
|
expect(res.body.data.columns.length).toBe(2);
|
|
expect(res.body.data.columns[0].column_name).toBe('name');
|
|
expect(res.body.data.columns[0].display_name).toBe('Name');
|
|
expect(res.body.data.columns[0].type).toBe('text');
|
|
expect(res.body.data.columns[1].column_name).toBe('email');
|
|
});
|
|
|
|
test('should return 404 for non-existent row', async () => {
|
|
const res = await request(app)
|
|
.get(`/api/v3/tables/${tableId}/rows/99999`)
|
|
.expect(404);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
expect(res.body.error.code).toBe('NOT_FOUND');
|
|
});
|
|
|
|
test('should return 404 for non-existent table', async () => {
|
|
const res = await request(app)
|
|
.get('/api/v3/tables/99999/rows/1')
|
|
.expect(404);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
expect(res.body.error.code).toBe('NOT_FOUND');
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// GET /api/v3/projects/:projectId/tables
|
|
// ============================================================
|
|
describe('GET /api/v3/projects/:projectId/tables', () => {
|
|
test('should return tables for specific project', async () => {
|
|
await createTestTable(projectId, 'proj_table');
|
|
|
|
const res = await request(app)
|
|
.get(`/api/v3/projects/${projectId}/tables`)
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
expect(Array.isArray(res.body.data)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// PUT /api/v3/tables/:tableId/rows/:rowId — Select Column Resolution
|
|
// ADR-098 fix: text values should be auto-resolved to numeric IDs
|
|
// ============================================================
|
|
describe('PUT /api/v3/tables/:tableId/rows/:rowId - select column resolution', () => {
|
|
let tableId, textColId, selectColId, relatedTableId;
|
|
|
|
// Helper to create a table row with auto-generated base_id
|
|
async function insertRow(tblId, data) {
|
|
const baseId = `row_${tblId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const result = await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
|
|
[tblId, baseId, JSON.stringify(data), userId]
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
// Create main table
|
|
tableId = await createTestTable(projectId, 'Select Test Table');
|
|
|
|
// Create related lookup table (like ticket states table 1706)
|
|
const relResult = await dbRun(
|
|
'INSERT INTO universal_tables (project_id, name, description) VALUES (?, ?, ?)',
|
|
[projectId, 'states_lookup', 'States Lookup']
|
|
);
|
|
relatedTableId = relResult.lastInsertRowid;
|
|
|
|
// Insert lookup options (like backlog=24275, done=24278)
|
|
await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
|
|
[relatedTableId, `state_backlog_${Date.now()}`, JSON.stringify({ name: 'backlog', color: '#6b7280' }), userId]
|
|
);
|
|
await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
|
|
[relatedTableId, `state_done_${Date.now()}`, JSON.stringify({ name: 'done', color: '#22c55e' }), userId]
|
|
);
|
|
await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
|
|
[relatedTableId, `state_inprogress_${Date.now()}`, JSON.stringify({ name: 'in progress', color: '#3b82f6' }), userId]
|
|
);
|
|
|
|
// Create text column
|
|
const col1 = await dbRun(
|
|
`INSERT INTO table_columns (table_id, column_name, display_name, type, config, is_required, order_index, is_visible)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[tableId, 'what', 'What', 'text', '{}', 0, 0, 1]
|
|
);
|
|
textColId = col1.lastInsertRowid;
|
|
|
|
// Create select column with relation (like state column in tickets)
|
|
const col2 = await dbRun(
|
|
`INSERT INTO table_columns (table_id, column_name, display_name, type, config, is_required, order_index, is_visible)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[tableId, 'state', 'State', 'select', JSON.stringify({
|
|
relation: {
|
|
enabled: true,
|
|
tableId: String(relatedTableId),
|
|
valueColumn: 'id',
|
|
labelColumn: 'name',
|
|
}
|
|
}), 0, 1, 1]
|
|
);
|
|
selectColId = col2.lastInsertRowid;
|
|
});
|
|
|
|
test('should convert text state value to numeric ID', async () => {
|
|
// Get one of the lookup row IDs for backlog
|
|
const backlogRow = await dbGet(
|
|
`SELECT id FROM table_rows WHERE table_id = ? AND data::text LIKE '%backlog%'`,
|
|
[relatedTableId]
|
|
);
|
|
|
|
// Create a row with numeric state
|
|
const rowId = await insertRow(tableId, { what: 'Test task', state: backlogRow.id });
|
|
|
|
// Update with TEXT value "done" — should be resolved to numeric ID
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { state: 'done' } })
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
// Verify the stored value is a number, not a string
|
|
const updatedRow = await dbGet(
|
|
'SELECT data FROM table_rows WHERE id = ? AND table_id = ?',
|
|
[rowId, tableId]
|
|
);
|
|
const storedData = typeof updatedRow.data === 'string' ? JSON.parse(updatedRow.data) : updatedRow.data;
|
|
expect(typeof storedData.state).toBe('number');
|
|
|
|
// It should match the "done" row's ID
|
|
const doneRow = await dbGet(
|
|
`SELECT id FROM table_rows WHERE table_id = ? AND data::text LIKE '%done%'`,
|
|
[relatedTableId]
|
|
);
|
|
expect(storedData.state).toBe(doneRow.id);
|
|
});
|
|
|
|
test('should pass through numeric state values unchanged', async () => {
|
|
const backlogRow = await dbGet(
|
|
`SELECT id FROM table_rows WHERE table_id = ? AND data::text LIKE '%backlog%'`,
|
|
[relatedTableId]
|
|
);
|
|
|
|
const rowId = await insertRow(tableId, { what: 'Test task', state: backlogRow.id });
|
|
|
|
const doneRow = await dbGet(
|
|
`SELECT id FROM table_rows WHERE table_id = ? AND data::text LIKE '%done%'`,
|
|
[relatedTableId]
|
|
);
|
|
|
|
// Update with numeric ID directly — should pass through
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { state: doneRow.id } })
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
const updatedRow = await dbGet(
|
|
'SELECT data FROM table_rows WHERE id = ? AND table_id = ?',
|
|
[rowId, tableId]
|
|
);
|
|
const storedData = typeof updatedRow.data === 'string' ? JSON.parse(updatedRow.data) : updatedRow.data;
|
|
expect(storedData.state).toBe(doneRow.id);
|
|
});
|
|
|
|
test('should return 400 for invalid text state value', async () => {
|
|
const rowId = await insertRow(tableId, { what: 'Test task', state: 1 });
|
|
|
|
// Try to set state to a non-existent text value
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { state: 'nonexistent_state' } })
|
|
.expect(400);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
expect(res.body.error.message).toContain('Invalid select values');
|
|
});
|
|
|
|
test('should handle case-insensitive text resolution', async () => {
|
|
const rowId = await insertRow(tableId, { what: 'Test task', state: 1 });
|
|
|
|
// Use mixed case "Backlog" — should resolve case-insensitively
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { state: 'Backlog' } })
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
const updatedRow = await dbGet(
|
|
'SELECT data FROM table_rows WHERE id = ? AND table_id = ?',
|
|
[rowId, tableId]
|
|
);
|
|
const storedData = typeof updatedRow.data === 'string' ? JSON.parse(updatedRow.data) : updatedRow.data;
|
|
expect(typeof storedData.state).toBe('number');
|
|
});
|
|
|
|
test('should not affect non-select columns', async () => {
|
|
const rowId = await insertRow(tableId, { what: 'Old task' });
|
|
|
|
// Update text column — should pass through without any resolution
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { what: 'New task name' } })
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
const updatedRow = await dbGet(
|
|
'SELECT data FROM table_rows WHERE id = ? AND table_id = ?',
|
|
[rowId, tableId]
|
|
);
|
|
const storedData = typeof updatedRow.data === 'string' ? JSON.parse(updatedRow.data) : updatedRow.data;
|
|
expect(storedData.what).toBe('New task name');
|
|
});
|
|
|
|
test('should convert numeric string to number for select columns', async () => {
|
|
const backlogRow = await dbGet(
|
|
`SELECT id FROM table_rows WHERE table_id = ? AND data::text LIKE '%backlog%'`,
|
|
[relatedTableId]
|
|
);
|
|
|
|
const rowId = await insertRow(tableId, { what: 'Test task' });
|
|
|
|
// Pass numeric ID as string "12345" — should be converted to number
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { state: String(backlogRow.id) } })
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
const updatedRow = await dbGet(
|
|
'SELECT data FROM table_rows WHERE id = ? AND table_id = ?',
|
|
[rowId, tableId]
|
|
);
|
|
const storedData = typeof updatedRow.data === 'string' ? JSON.parse(updatedRow.data) : updatedRow.data;
|
|
expect(typeof storedData.state).toBe('number');
|
|
expect(storedData.state).toBe(backlogRow.id);
|
|
});
|
|
|
|
test('should reject invalid numeric ID for relation-based select', async () => {
|
|
const rowId = await insertRow(tableId, { what: 'Test task', state: 1 });
|
|
|
|
// Pass a numeric ID that does not exist in the related table
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { state: 999999 } })
|
|
.expect(400);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
expect(res.body.error.message).toContain('Invalid select values');
|
|
expect(res.body.error.message).toContain('invalid option ID');
|
|
});
|
|
|
|
test('should reject invalid numeric string ID for relation-based select', async () => {
|
|
const rowId = await insertRow(tableId, { what: 'Test task', state: 1 });
|
|
|
|
// Pass a numeric string ID that does not exist in the related table
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { state: '999999' } })
|
|
.expect(400);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
expect(res.body.error.message).toContain('Invalid select values');
|
|
expect(res.body.error.message).toContain('invalid option ID');
|
|
});
|
|
|
|
test('should include valid IDs in error message for invalid numeric ID', async () => {
|
|
const rowId = await insertRow(tableId, { what: 'Test task' });
|
|
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { state: 999999 } })
|
|
.expect(400);
|
|
|
|
// Error message should list valid IDs so the caller knows what to use
|
|
expect(res.body.error.message).toContain('Valid IDs:');
|
|
});
|
|
|
|
test('should include valid labels in error message for invalid text value', async () => {
|
|
const rowId = await insertRow(tableId, { what: 'Test task' });
|
|
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { state: 'nonexistent_state' } })
|
|
.expect(400);
|
|
|
|
// Error message should list valid text labels
|
|
expect(res.body.error.message).toContain('backlog');
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// PUT /api/v3/tables/:tableId/rows/:rowId — Static Options Select
|
|
// ============================================================
|
|
describe('PUT /api/v3/tables/:tableId/rows/:rowId - static options select', () => {
|
|
let tableId, phaseColId;
|
|
|
|
// Helper to create a table row with auto-generated base_id
|
|
async function insertRow(tblId, data) {
|
|
const baseId = `row_${tblId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
const result = await dbRun(
|
|
`INSERT INTO table_rows (table_id, base_id, data, created_by) VALUES (?, ?, ?, ?)`,
|
|
[tblId, baseId, JSON.stringify(data), userId]
|
|
);
|
|
return result.lastInsertRowid;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tableId = await createTestTable(projectId, 'Phase Test Table');
|
|
|
|
// Create select column with static options (like phase/cycle)
|
|
const col = await dbRun(
|
|
`INSERT INTO table_columns (table_id, column_name, display_name, type, config, is_required, order_index, is_visible)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
[tableId, 'phase', 'Phase', 'select', JSON.stringify({
|
|
options: [
|
|
{ label: 'Phase 0', value: 'phase_0', color: '#ef4444' },
|
|
{ label: 'Phase 1', value: 'phase_1', color: '#f59e0b' },
|
|
{ label: 'Phase 2', value: 'phase_2', color: '#3b82f6' },
|
|
]
|
|
}), 0, 0, 1]
|
|
);
|
|
phaseColId = col.lastInsertRowid;
|
|
});
|
|
|
|
test('should accept valid static option value', async () => {
|
|
const rowId = await insertRow(tableId, { phase: 'phase_0' });
|
|
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { phase: 'phase_1' } })
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
});
|
|
|
|
test('should resolve static option by label text', async () => {
|
|
const rowId = await insertRow(tableId, { phase: 'phase_0' });
|
|
|
|
// Use label text "Phase 1" instead of value "phase_1"
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { phase: 'Phase 1' } })
|
|
.expect(200);
|
|
|
|
expect(res.body.success).toBe(true);
|
|
|
|
const updatedRow = await dbGet(
|
|
'SELECT data FROM table_rows WHERE id = ? AND table_id = ?',
|
|
[rowId, tableId]
|
|
);
|
|
const storedData = typeof updatedRow.data === 'string' ? JSON.parse(updatedRow.data) : updatedRow.data;
|
|
expect(storedData.phase).toBe('phase_1');
|
|
});
|
|
|
|
test('should reject invalid static option', async () => {
|
|
const rowId = await insertRow(tableId, { phase: 'phase_0' });
|
|
|
|
const res = await request(app)
|
|
.put(`/api/v3/tables/${tableId}/rows/${rowId}`)
|
|
.send({ data: { phase: 'Phase 99' } })
|
|
.expect(400);
|
|
|
|
expect(res.body.success).toBe(false);
|
|
expect(res.body.error.message).toContain('Invalid select values');
|
|
});
|
|
});
|
|
});
|