godcrm/backend/routes/v3/__tests__/tables-crud.test.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

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');
});
});
});