Files
n8n-mcp/tests/unit/mcp/handlers-manage-datatable.test.ts
czlonkowski 4e2da6c652 feat: replace n8n_create_data_table with n8n_manage_datatable (10 actions)
Replaces the single-purpose n8n_create_data_table tool with a comprehensive
n8n_manage_datatable tool covering all 10 n8n data table API endpoints:

Table operations: createTable, listTables, getTable, updateTable, deleteTable
Row operations: getRows, insertRows, updateRows, upsertRows, deleteRows

- Filter system with and/or logic and 8 condition operators
- Dry-run support for updateRows, upsertRows, deleteRows
- Pagination, sorting, and full-text search for row listing
- 9 new N8nApiClient methods for all data table endpoints
- Shared error handler and consolidated Zod schemas
- Comprehensive tool documentation with examples per action
- 36 handler tests + 18 API client tests

BREAKING: n8n_create_data_table removed. Use n8n_manage_datatable with
action="createTable" instead.

Based on work by @djakielski in PR #646.
Co-Authored-By: Dominik Jakielski <dominik.jakielski@urlaubsguru.de>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:50:29 +01:00

728 lines
23 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { N8nApiClient } from '@/services/n8n-api-client';
import { N8nApiError } from '@/utils/n8n-errors';
// Mock dependencies
vi.mock('@/services/n8n-api-client');
vi.mock('@/config/n8n-api', () => ({
getN8nApiConfig: vi.fn(),
}));
vi.mock('@/services/n8n-validation', () => ({
validateWorkflowStructure: vi.fn(),
hasWebhookTrigger: vi.fn(),
getWebhookUrl: vi.fn(),
}));
vi.mock('@/utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
Logger: vi.fn().mockImplementation(() => ({
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
})),
LogLevel: {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
},
}));
describe('Data Table Handlers (n8n_manage_datatable)', () => {
let mockApiClient: any;
let handlers: any;
let getN8nApiConfig: any;
beforeEach(async () => {
vi.clearAllMocks();
// Setup mock API client with all data table methods
mockApiClient = {
createWorkflow: vi.fn(),
getWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
listWorkflows: vi.fn(),
triggerWebhook: vi.fn(),
getExecution: vi.fn(),
listExecutions: vi.fn(),
deleteExecution: vi.fn(),
healthCheck: vi.fn(),
createDataTable: vi.fn(),
listDataTables: vi.fn(),
getDataTable: vi.fn(),
updateDataTable: vi.fn(),
deleteDataTable: vi.fn(),
getDataTableRows: vi.fn(),
insertDataTableRows: vi.fn(),
updateDataTableRows: vi.fn(),
upsertDataTableRow: vi.fn(),
deleteDataTableRows: vi.fn(),
};
// Import mocked modules
getN8nApiConfig = (await import('@/config/n8n-api')).getN8nApiConfig;
// Mock the API config
vi.mocked(getN8nApiConfig).mockReturnValue({
baseUrl: 'https://n8n.test.com',
apiKey: 'test-key',
timeout: 30000,
maxRetries: 3,
});
// Mock the N8nApiClient constructor
vi.mocked(N8nApiClient).mockImplementation(() => mockApiClient);
// Import handlers module after setting up mocks
handlers = await import('@/mcp/handlers-n8n-manager');
});
afterEach(() => {
if (handlers) {
const clientGetter = handlers.getN8nApiClient;
if (clientGetter) {
vi.mocked(getN8nApiConfig).mockReturnValue(null);
clientGetter();
}
}
});
// ========================================================================
// handleCreateTable
// ========================================================================
describe('handleCreateTable', () => {
it('should create data table with name and columns successfully', async () => {
const createdTable = {
id: 'dt-123',
name: 'My Data Table',
columns: [
{ id: 'col-1', name: 'email', type: 'string', index: 0 },
{ id: 'col-2', name: 'age', type: 'number', index: 1 },
],
};
mockApiClient.createDataTable.mockResolvedValue(createdTable);
const result = await handlers.handleCreateTable({
name: 'My Data Table',
columns: [
{ name: 'email', type: 'string' },
{ name: 'age', type: 'number' },
],
});
expect(result).toEqual({
success: true,
data: { id: 'dt-123', name: 'My Data Table' },
message: 'Data table "My Data Table" created with ID: dt-123',
});
expect(mockApiClient.createDataTable).toHaveBeenCalledWith({
name: 'My Data Table',
columns: [
{ name: 'email', type: 'string' },
{ name: 'age', type: 'number' },
],
});
});
it('should create data table with name only (no columns)', async () => {
const createdTable = {
id: 'dt-456',
name: 'Empty Table',
};
mockApiClient.createDataTable.mockResolvedValue(createdTable);
const result = await handlers.handleCreateTable({
name: 'Empty Table',
});
expect(result).toEqual({
success: true,
data: { id: 'dt-456', name: 'Empty Table' },
message: 'Data table "Empty Table" created with ID: dt-456',
});
expect(mockApiClient.createDataTable).toHaveBeenCalledWith({
name: 'Empty Table',
});
});
it('should return error when API returns empty response (null)', async () => {
mockApiClient.createDataTable.mockResolvedValue(null);
const result = await handlers.handleCreateTable({
name: 'Ghost Table',
});
expect(result).toEqual({
success: false,
error: 'Data table creation failed: n8n API returned an empty or invalid response',
});
});
it('should return error when API call fails', async () => {
const apiError = new Error('Data table creation failed on the server');
mockApiClient.createDataTable.mockRejectedValue(apiError);
const result = await handlers.handleCreateTable({
name: 'Broken Table',
});
expect(result).toEqual({
success: false,
error: 'Data table creation failed on the server',
});
});
it('should return Zod validation error when name is missing', async () => {
const result = await handlers.handleCreateTable({});
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid input');
expect(result.details).toHaveProperty('errors');
});
it('should return error when n8n API is not configured', async () => {
vi.mocked(getN8nApiConfig).mockReturnValue(null);
const result = await handlers.handleCreateTable({
name: 'Test Table',
});
expect(result).toEqual({
success: false,
error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.',
});
});
it('should return structured error for N8nApiError', async () => {
const apiError = new N8nApiError('Feature not available', 402, 'PAYMENT_REQUIRED', { plan: 'enterprise' });
mockApiClient.createDataTable.mockRejectedValue(apiError);
const result = await handlers.handleCreateTable({
name: 'Enterprise Table',
});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.code).toBe('PAYMENT_REQUIRED');
expect(result.details).toEqual({ plan: 'enterprise' });
});
it('should return Unknown error when a non-Error value is thrown', async () => {
mockApiClient.createDataTable.mockRejectedValue('string-error');
const result = await handlers.handleCreateTable({
name: 'Error Table',
});
expect(result).toEqual({
success: false,
error: 'Unknown error occurred',
});
});
});
// ========================================================================
// handleListTables
// ========================================================================
describe('handleListTables', () => {
it('should list tables successfully', async () => {
const tables = [
{ id: 'dt-1', name: 'Table One' },
{ id: 'dt-2', name: 'Table Two' },
];
mockApiClient.listDataTables.mockResolvedValue({ data: tables, nextCursor: null });
const result = await handlers.handleListTables({});
expect(result).toEqual({
success: true,
data: {
tables,
count: 2,
nextCursor: undefined,
},
});
});
it('should return empty list when no tables exist', async () => {
mockApiClient.listDataTables.mockResolvedValue({ data: [], nextCursor: null });
const result = await handlers.handleListTables({});
expect(result).toEqual({
success: true,
data: {
tables: [],
count: 0,
nextCursor: undefined,
},
});
});
it('should pass pagination params (limit, cursor)', async () => {
mockApiClient.listDataTables.mockResolvedValue({
data: [{ id: 'dt-3', name: 'Page Two' }],
nextCursor: 'cursor-next',
});
const result = await handlers.handleListTables({ limit: 10, cursor: 'cursor-abc' });
expect(mockApiClient.listDataTables).toHaveBeenCalledWith({ limit: 10, cursor: 'cursor-abc' });
expect(result.success).toBe(true);
expect(result.data.nextCursor).toBe('cursor-next');
});
it('should handle API error', async () => {
mockApiClient.listDataTables.mockRejectedValue(new Error('Server down'));
const result = await handlers.handleListTables({});
expect(result.success).toBe(false);
expect(result.error).toBe('Server down');
});
});
// ========================================================================
// handleGetTable
// ========================================================================
describe('handleGetTable', () => {
it('should get table successfully', async () => {
const table = { id: 'dt-1', name: 'My Table', columns: [] };
mockApiClient.getDataTable.mockResolvedValue(table);
const result = await handlers.handleGetTable({ tableId: 'dt-1' });
expect(result).toEqual({
success: true,
data: table,
});
expect(mockApiClient.getDataTable).toHaveBeenCalledWith('dt-1');
});
it('should return error on 404', async () => {
const notFoundError = new N8nApiError('Data table not found', 404, 'NOT_FOUND');
mockApiClient.getDataTable.mockRejectedValue(notFoundError);
const result = await handlers.handleGetTable({ tableId: 'dt-nonexistent' });
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.code).toBe('NOT_FOUND');
});
it('should return Zod validation error when tableId is missing', async () => {
const result = await handlers.handleGetTable({});
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid input');
expect(result.details).toHaveProperty('errors');
});
});
// ========================================================================
// handleUpdateTable
// ========================================================================
describe('handleUpdateTable', () => {
it('should rename table successfully', async () => {
const updatedTable = { id: 'dt-1', name: 'Renamed Table' };
mockApiClient.updateDataTable.mockResolvedValue(updatedTable);
const result = await handlers.handleUpdateTable({ tableId: 'dt-1', name: 'Renamed Table' });
expect(result).toEqual({
success: true,
data: updatedTable,
message: 'Data table renamed to "Renamed Table"',
});
expect(mockApiClient.updateDataTable).toHaveBeenCalledWith('dt-1', { name: 'Renamed Table' });
});
it('should return Zod validation error when tableId is missing', async () => {
const result = await handlers.handleUpdateTable({ name: 'New Name' });
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid input');
expect(result.details).toHaveProperty('errors');
});
it('should return error when API call fails', async () => {
mockApiClient.updateDataTable.mockRejectedValue(new Error('Update failed'));
const result = await handlers.handleUpdateTable({ tableId: 'dt-1', name: 'New Name' });
expect(result.success).toBe(false);
expect(result.error).toBe('Update failed');
});
});
// ========================================================================
// handleDeleteTable
// ========================================================================
describe('handleDeleteTable', () => {
it('should delete table successfully', async () => {
mockApiClient.deleteDataTable.mockResolvedValue(undefined);
const result = await handlers.handleDeleteTable({ tableId: 'dt-1' });
expect(result).toEqual({
success: true,
message: 'Data table dt-1 deleted successfully',
});
expect(mockApiClient.deleteDataTable).toHaveBeenCalledWith('dt-1');
});
it('should return error on 404', async () => {
const notFoundError = new N8nApiError('Data table not found', 404, 'NOT_FOUND');
mockApiClient.deleteDataTable.mockRejectedValue(notFoundError);
const result = await handlers.handleDeleteTable({ tableId: 'dt-nonexistent' });
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.code).toBe('NOT_FOUND');
});
});
// ========================================================================
// handleGetRows
// ========================================================================
describe('handleGetRows', () => {
it('should get rows with default params', async () => {
const rows = [
{ id: 1, email: 'a@b.com', score: 10 },
{ id: 2, email: 'c@d.com', score: 20 },
];
mockApiClient.getDataTableRows.mockResolvedValue({ data: rows, nextCursor: null });
const result = await handlers.handleGetRows({ tableId: 'dt-1' });
expect(result).toEqual({
success: true,
data: {
rows,
count: 2,
nextCursor: undefined,
},
});
expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', {});
});
it('should pass filter, sort, and search params', async () => {
mockApiClient.getDataTableRows.mockResolvedValue({ data: [], nextCursor: null });
await handlers.handleGetRows({
tableId: 'dt-1',
limit: 50,
sortBy: 'name:asc',
search: 'john',
});
expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', {
limit: 50,
sortBy: 'name:asc',
search: 'john',
});
});
it('should serialize object filter to JSON string', async () => {
mockApiClient.getDataTableRows.mockResolvedValue({ data: [], nextCursor: null });
const objectFilter = {
type: 'and' as const,
filters: [{ columnName: 'status', condition: 'eq' as const, value: 'active' }],
};
await handlers.handleGetRows({
tableId: 'dt-1',
filter: objectFilter,
});
expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', {
filter: JSON.stringify(objectFilter),
});
});
it('should pass through string filter as-is', async () => {
mockApiClient.getDataTableRows.mockResolvedValue({ data: [], nextCursor: null });
await handlers.handleGetRows({
tableId: 'dt-1',
filter: '{"type":"and","filters":[]}',
});
expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', {
filter: '{"type":"and","filters":[]}',
});
});
});
// ========================================================================
// handleInsertRows
// ========================================================================
describe('handleInsertRows', () => {
it('should insert rows successfully', async () => {
const insertResult = { insertedCount: 2, ids: [1, 2] };
mockApiClient.insertDataTableRows.mockResolvedValue(insertResult);
const result = await handlers.handleInsertRows({
tableId: 'dt-1',
data: [
{ email: 'a@b.com', score: 10 },
{ email: 'c@d.com', score: 20 },
],
});
expect(result).toEqual({
success: true,
data: insertResult,
message: 'Rows inserted into data table dt-1',
});
expect(mockApiClient.insertDataTableRows).toHaveBeenCalledWith('dt-1', {
data: [
{ email: 'a@b.com', score: 10 },
{ email: 'c@d.com', score: 20 },
],
});
});
it('should pass returnType to the API client', async () => {
const insertResult = [{ id: 1, email: 'a@b.com', score: 10 }];
mockApiClient.insertDataTableRows.mockResolvedValue(insertResult);
const result = await handlers.handleInsertRows({
tableId: 'dt-1',
data: [{ email: 'a@b.com', score: 10 }],
returnType: 'all',
});
expect(result.success).toBe(true);
expect(result.data).toEqual(insertResult);
expect(mockApiClient.insertDataTableRows).toHaveBeenCalledWith('dt-1', {
data: [{ email: 'a@b.com', score: 10 }],
returnType: 'all',
});
});
it('should return Zod validation error when data is empty array', async () => {
const result = await handlers.handleInsertRows({
tableId: 'dt-1',
data: [],
});
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid input');
expect(result.details).toHaveProperty('errors');
});
});
// ========================================================================
// handleUpdateRows
// ========================================================================
describe('handleUpdateRows', () => {
it('should update rows successfully', async () => {
const updateResult = { updatedCount: 3 };
mockApiClient.updateDataTableRows.mockResolvedValue(updateResult);
const filter = {
type: 'and' as const,
filters: [{ columnName: 'status', condition: 'eq' as const, value: 'inactive' }],
};
const result = await handlers.handleUpdateRows({
tableId: 'dt-1',
filter,
data: { status: 'active' },
});
expect(result).toEqual({
success: true,
data: updateResult,
message: 'Rows updated successfully',
});
expect(mockApiClient.updateDataTableRows).toHaveBeenCalledWith('dt-1', {
filter,
data: { status: 'active' },
});
});
it('should support dryRun mode', async () => {
const dryRunResult = { matchedCount: 5 };
mockApiClient.updateDataTableRows.mockResolvedValue(dryRunResult);
const filter = {
filters: [{ columnName: 'score', condition: 'lt' as const, value: 5 }],
};
const result = await handlers.handleUpdateRows({
tableId: 'dt-1',
filter,
data: { status: 'low' },
dryRun: true,
});
expect(result.success).toBe(true);
expect(result.message).toBe('Dry run: rows matched (no changes applied)');
expect(mockApiClient.updateDataTableRows).toHaveBeenCalledWith('dt-1', {
filter: { type: 'and', ...filter },
data: { status: 'low' },
dryRun: true,
});
});
it('should return error on API failure', async () => {
mockApiClient.updateDataTableRows.mockRejectedValue(new Error('Conflict'));
const result = await handlers.handleUpdateRows({
tableId: 'dt-1',
filter: { filters: [{ columnName: 'id', condition: 'eq' as const, value: 1 }] },
data: { name: 'test' },
});
expect(result.success).toBe(false);
expect(result.error).toBe('Conflict');
});
});
// ========================================================================
// handleUpsertRows
// ========================================================================
describe('handleUpsertRows', () => {
it('should upsert row successfully', async () => {
const upsertResult = { action: 'updated', row: { id: 1, email: 'a@b.com', score: 15 } };
mockApiClient.upsertDataTableRow.mockResolvedValue(upsertResult);
const filter = {
filters: [{ columnName: 'email', condition: 'eq' as const, value: 'a@b.com' }],
};
const result = await handlers.handleUpsertRows({
tableId: 'dt-1',
filter,
data: { score: 15 },
});
expect(result).toEqual({
success: true,
data: upsertResult,
message: 'Row upserted successfully',
});
expect(mockApiClient.upsertDataTableRow).toHaveBeenCalledWith('dt-1', {
filter: { type: 'and', ...filter },
data: { score: 15 },
});
});
it('should support dryRun mode', async () => {
const dryRunResult = { action: 'would_update', matchedRows: 1 };
mockApiClient.upsertDataTableRow.mockResolvedValue(dryRunResult);
const filter = {
filters: [{ columnName: 'email', condition: 'eq' as const, value: 'a@b.com' }],
};
const result = await handlers.handleUpsertRows({
tableId: 'dt-1',
filter,
data: { score: 20 },
dryRun: true,
});
expect(result.success).toBe(true);
expect(result.message).toBe('Dry run: upsert previewed (no changes applied)');
});
it('should return error on API failure', async () => {
const apiError = new N8nApiError('Server error', 500, 'INTERNAL_ERROR');
mockApiClient.upsertDataTableRow.mockRejectedValue(apiError);
const result = await handlers.handleUpsertRows({
tableId: 'dt-1',
filter: { filters: [{ columnName: 'id', condition: 'eq' as const, value: 1 }] },
data: { name: 'test' },
});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.code).toBe('INTERNAL_ERROR');
});
});
// ========================================================================
// handleDeleteRows
// ========================================================================
describe('handleDeleteRows', () => {
it('should delete rows successfully', async () => {
const deleteResult = { deletedCount: 2 };
mockApiClient.deleteDataTableRows.mockResolvedValue(deleteResult);
const filter = {
filters: [{ columnName: 'status', condition: 'eq' as const, value: 'deleted' }],
};
const result = await handlers.handleDeleteRows({
tableId: 'dt-1',
filter,
});
expect(result).toEqual({
success: true,
data: deleteResult,
message: 'Rows deleted successfully',
});
expect(mockApiClient.deleteDataTableRows).toHaveBeenCalledWith('dt-1', {
filter: JSON.stringify({ type: 'and', ...filter }),
});
});
it('should serialize filter object to JSON string for API call', async () => {
mockApiClient.deleteDataTableRows.mockResolvedValue({ deletedCount: 1 });
const filter = {
type: 'or' as const,
filters: [
{ columnName: 'score', condition: 'lt' as const, value: 0 },
{ columnName: 'status', condition: 'eq' as const, value: 'spam' },
],
};
await handlers.handleDeleteRows({ tableId: 'dt-1', filter });
expect(mockApiClient.deleteDataTableRows).toHaveBeenCalledWith('dt-1', {
filter: JSON.stringify(filter),
});
});
it('should support dryRun mode', async () => {
const dryRunResult = { matchedCount: 4 };
mockApiClient.deleteDataTableRows.mockResolvedValue(dryRunResult);
const filter = {
filters: [{ columnName: 'active', condition: 'eq' as const, value: false }],
};
const result = await handlers.handleDeleteRows({
tableId: 'dt-1',
filter,
dryRun: true,
});
expect(result.success).toBe(true);
expect(result.message).toBe('Dry run: rows matched for deletion (no changes applied)');
expect(mockApiClient.deleteDataTableRows).toHaveBeenCalledWith('dt-1', {
filter: JSON.stringify({ type: 'and', ...filter }),
dryRun: true,
});
});
});
});