mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-22 10:23:08 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5665632af | ||
|
|
be3d07dbdc |
31
CHANGELOG.md
31
CHANGELOG.md
@@ -7,6 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.40.1] - 2026-03-21
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`n8n_manage_datatable` row operations broken by MCP transport serialization**: `data` parameter received as string instead of JSON — added `z.preprocess` coercers for array/object/filter params
|
||||
- **`n8n_manage_datatable` filter/sortBy URL encoding**: n8n API requires URL-encoded query params — added `encodeURIComponent()` for filter and sortBy in getRows and deleteRows
|
||||
- **`json` column type rejected by n8n API**: Removed `json` from column type enum (n8n only accepts string/number/boolean/date)
|
||||
- **Garbled 404 error messages**: Fixed `N8nNotFoundError` constructor — API error messages are now passed through cleanly instead of being wrapped in "Resource with ID ... not found"
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.40.0] - 2026-03-21
|
||||
|
||||
### Changed
|
||||
|
||||
- **`n8n_manage_datatable` MCP tool** (replaces `n8n_create_data_table`): Full data table management 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 (eq, neq, like, ilike, gt, gte, lt, lte)
|
||||
- Dry-run support for updateRows, upsertRows, deleteRows
|
||||
- Pagination, sorting, and full-text search for row listing
|
||||
- Shared error handler and consolidated Zod schemas for consistency
|
||||
- 9 new `N8nApiClient` methods for all data table endpoints
|
||||
- **`projectId` parameter for `n8n_create_workflow`**: Create workflows directly in a specific team project (enterprise feature)
|
||||
|
||||
### Breaking
|
||||
|
||||
- `n8n_create_data_table` tool replaced by `n8n_manage_datatable` with `action: "createTable"`
|
||||
|
||||
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||
|
||||
## [2.38.0] - 2026-03-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "n8n-mcp",
|
||||
"version": "2.38.0",
|
||||
"version": "2.40.1",
|
||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
WebhookRequest,
|
||||
McpToolResponse,
|
||||
ExecutionFilterOptions,
|
||||
ExecutionMode
|
||||
ExecutionMode,
|
||||
} from '../types/n8n-api';
|
||||
import type { TriggerType, TestWorkflowInput } from '../triggers/types';
|
||||
import {
|
||||
@@ -383,6 +383,7 @@ const createWorkflowSchema = z.object({
|
||||
executionTimeout: z.number().optional(),
|
||||
errorWorkflow: z.string().optional(),
|
||||
}).optional(),
|
||||
projectId: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateWorkflowSchema = z.object({
|
||||
@@ -1974,7 +1975,7 @@ export async function handleDiagnostic(request: any, context?: InstanceContext):
|
||||
|
||||
// Check which tools are available
|
||||
const documentationTools = 7; // Base documentation tools (after v2.26.0 consolidation)
|
||||
const managementTools = apiConfigured ? 13 : 0; // Management tools requiring API (includes n8n_deploy_template)
|
||||
const managementTools = apiConfigured ? 14 : 0; // Management tools requiring API (includes n8n_manage_datatable)
|
||||
const totalTools = documentationTools + managementTools;
|
||||
|
||||
// Check npm version
|
||||
@@ -2688,3 +2689,258 @@ export async function handleTriggerWebhookWorkflow(args: unknown, context?: Inst
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Data Table Handlers
|
||||
// ========================================================================
|
||||
|
||||
// Shared Zod schemas for data table operations
|
||||
const dataTableFilterConditionSchema = z.object({
|
||||
columnName: z.string().min(1),
|
||||
condition: z.enum(['eq', 'neq', 'like', 'ilike', 'gt', 'gte', 'lt', 'lte']),
|
||||
value: z.any(),
|
||||
});
|
||||
|
||||
const dataTableFilterSchema = z.object({
|
||||
type: z.enum(['and', 'or']).optional().default('and'),
|
||||
filters: z.array(dataTableFilterConditionSchema).min(1, 'At least one filter condition is required'),
|
||||
});
|
||||
|
||||
// Shared base schema for actions requiring a tableId
|
||||
const tableIdSchema = z.object({
|
||||
tableId: z.string().min(1, 'tableId is required'),
|
||||
});
|
||||
|
||||
// Per-action Zod schemas
|
||||
const createTableSchema = z.object({
|
||||
name: z.string().min(1, 'Table name cannot be empty'),
|
||||
columns: z.array(z.object({
|
||||
name: z.string().min(1, 'Column name cannot be empty'),
|
||||
type: z.enum(['string', 'number', 'boolean', 'date']).optional(),
|
||||
})).optional(),
|
||||
});
|
||||
|
||||
const listTablesSchema = z.object({
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
|
||||
const updateTableSchema = tableIdSchema.extend({
|
||||
name: z.string().min(1, 'New table name cannot be empty'),
|
||||
});
|
||||
|
||||
// MCP transports may serialize JSON objects/arrays as strings.
|
||||
// Parse them back, but return the original value on failure so Zod reports a proper type error.
|
||||
function tryParseJson(val: unknown): unknown {
|
||||
if (typeof val !== 'string') return val;
|
||||
try { return JSON.parse(val); } catch { return val; }
|
||||
}
|
||||
|
||||
const coerceJsonArray = z.preprocess(tryParseJson, z.array(z.record(z.unknown())));
|
||||
const coerceJsonObject = z.preprocess(tryParseJson, z.record(z.unknown()));
|
||||
const coerceJsonFilter = z.preprocess(tryParseJson, dataTableFilterSchema);
|
||||
|
||||
const getRowsSchema = tableIdSchema.extend({
|
||||
limit: z.number().min(1).max(100).optional(),
|
||||
cursor: z.string().optional(),
|
||||
filter: z.union([coerceJsonFilter, z.string()]).optional(),
|
||||
sortBy: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
const insertRowsSchema = tableIdSchema.extend({
|
||||
data: coerceJsonArray.pipe(z.array(z.record(z.unknown())).min(1, 'At least one row is required')),
|
||||
returnType: z.enum(['count', 'id', 'all']).optional(),
|
||||
});
|
||||
|
||||
// Shared schema for update/upsert (identical structure)
|
||||
const mutateRowsSchema = tableIdSchema.extend({
|
||||
filter: coerceJsonFilter,
|
||||
data: coerceJsonObject,
|
||||
returnData: z.boolean().optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const deleteRowsSchema = tableIdSchema.extend({
|
||||
filter: coerceJsonFilter,
|
||||
returnData: z.boolean().optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
});
|
||||
|
||||
function handleDataTableError(error: unknown): McpToolResponse {
|
||||
if (error instanceof z.ZodError) {
|
||||
return { success: false, error: 'Invalid input', details: { errors: error.errors } };
|
||||
}
|
||||
if (error instanceof N8nApiError) {
|
||||
return {
|
||||
success: false,
|
||||
error: getUserFriendlyErrorMessage(error),
|
||||
code: error.code,
|
||||
details: error.details as Record<string, unknown> | undefined,
|
||||
};
|
||||
}
|
||||
return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' };
|
||||
}
|
||||
|
||||
export async function handleCreateTable(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = createTableSchema.parse(args);
|
||||
const dataTable = await client.createDataTable(input);
|
||||
if (!dataTable || !dataTable.id) {
|
||||
return { success: false, error: 'Data table creation failed: n8n API returned an empty or invalid response' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: { id: dataTable.id, name: dataTable.name },
|
||||
message: `Data table "${dataTable.name}" created with ID: ${dataTable.id}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleListTables(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const input = listTablesSchema.parse(args || {});
|
||||
const result = await client.listDataTables(input);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
tables: result.data,
|
||||
count: result.data.length,
|
||||
nextCursor: result.nextCursor || undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetTable(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { tableId } = tableIdSchema.parse(args);
|
||||
const dataTable = await client.getDataTable(tableId);
|
||||
return { success: true, data: dataTable };
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleUpdateTable(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { tableId, name } = updateTableSchema.parse(args);
|
||||
const dataTable = await client.updateDataTable(tableId, { name });
|
||||
return {
|
||||
success: true,
|
||||
data: dataTable,
|
||||
message: `Data table renamed to "${dataTable.name}"`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteTable(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { tableId } = tableIdSchema.parse(args);
|
||||
await client.deleteDataTable(tableId);
|
||||
return { success: true, message: `Data table ${tableId} deleted successfully` };
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleGetRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { tableId, filter, sortBy, ...params } = getRowsSchema.parse(args);
|
||||
const queryParams: Record<string, unknown> = { ...params };
|
||||
if (filter) {
|
||||
const filterStr = typeof filter === 'string' ? filter : JSON.stringify(filter);
|
||||
queryParams.filter = encodeURIComponent(filterStr);
|
||||
}
|
||||
if (sortBy) {
|
||||
queryParams.sortBy = encodeURIComponent(sortBy);
|
||||
}
|
||||
const result = await client.getDataTableRows(tableId, queryParams as any);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
rows: result.data,
|
||||
count: result.data.length,
|
||||
nextCursor: result.nextCursor || undefined,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleInsertRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { tableId, ...params } = insertRowsSchema.parse(args);
|
||||
const result = await client.insertDataTableRows(tableId, params);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: `Rows inserted into data table ${tableId}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleUpdateRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { tableId, ...params } = mutateRowsSchema.parse(args);
|
||||
const result = await client.updateDataTableRows(tableId, params);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: params.dryRun ? 'Dry run: rows matched (no changes applied)' : 'Rows updated successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleUpsertRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { tableId, ...params } = mutateRowsSchema.parse(args);
|
||||
const result = await client.upsertDataTableRow(tableId, params);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: params.dryRun ? 'Dry run: upsert previewed (no changes applied)' : 'Row upserted successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleDeleteRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
|
||||
try {
|
||||
const client = ensureApiConfigured(context);
|
||||
const { tableId, filter, ...params } = deleteRowsSchema.parse(args);
|
||||
const queryParams = {
|
||||
filter: encodeURIComponent(JSON.stringify(filter)),
|
||||
...params,
|
||||
};
|
||||
const result = await client.deleteDataTableRows(tableId, queryParams as any);
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: params.dryRun ? 'Dry run: rows matched for deletion (no changes applied)' : 'Rows deleted successfully',
|
||||
};
|
||||
} catch (error) {
|
||||
return handleDataTableError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1029,6 +1029,11 @@ export class N8NDocumentationMCPServer {
|
||||
? { valid: true, errors: [] }
|
||||
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
|
||||
break;
|
||||
case 'n8n_manage_datatable':
|
||||
validationResult = args.action
|
||||
? { valid: true, errors: [] }
|
||||
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
|
||||
break;
|
||||
case 'n8n_deploy_template':
|
||||
// Requires templateId parameter
|
||||
validationResult = args.templateId !== undefined
|
||||
@@ -1496,6 +1501,26 @@ export class N8NDocumentationMCPServer {
|
||||
if (!this.repository) throw new Error('Repository not initialized');
|
||||
return n8nHandlers.handleDeployTemplate(args, this.templateService, this.repository, this.instanceContext);
|
||||
|
||||
case 'n8n_manage_datatable': {
|
||||
this.validateToolParams(name, args, ['action']);
|
||||
const dtAction = args.action;
|
||||
// Each handler validates its own inputs via Zod schemas
|
||||
switch (dtAction) {
|
||||
case 'createTable': return n8nHandlers.handleCreateTable(args, this.instanceContext);
|
||||
case 'listTables': return n8nHandlers.handleListTables(args, this.instanceContext);
|
||||
case 'getTable': return n8nHandlers.handleGetTable(args, this.instanceContext);
|
||||
case 'updateTable': return n8nHandlers.handleUpdateTable(args, this.instanceContext);
|
||||
case 'deleteTable': return n8nHandlers.handleDeleteTable(args, this.instanceContext);
|
||||
case 'getRows': return n8nHandlers.handleGetRows(args, this.instanceContext);
|
||||
case 'insertRows': return n8nHandlers.handleInsertRows(args, this.instanceContext);
|
||||
case 'updateRows': return n8nHandlers.handleUpdateRows(args, this.instanceContext);
|
||||
case 'upsertRows': return n8nHandlers.handleUpsertRows(args, this.instanceContext);
|
||||
case 'deleteRows': return n8nHandlers.handleDeleteRows(args, this.instanceContext);
|
||||
default:
|
||||
throw new Error(`Unknown action: ${dtAction}. Valid actions: createTable, listTables, getTable, updateTable, deleteTable, getRows, insertRows, updateRows, upsertRows, deleteRows`);
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
n8nTestWorkflowDoc,
|
||||
n8nExecutionsDoc,
|
||||
n8nWorkflowVersionsDoc,
|
||||
n8nDeployTemplateDoc
|
||||
n8nDeployTemplateDoc,
|
||||
n8nManageDatatableDoc
|
||||
} from './workflow_management';
|
||||
|
||||
// Combine all tool documentations into a single object
|
||||
@@ -60,7 +61,8 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
|
||||
n8n_test_workflow: n8nTestWorkflowDoc,
|
||||
n8n_executions: n8nExecutionsDoc,
|
||||
n8n_workflow_versions: n8nWorkflowVersionsDoc,
|
||||
n8n_deploy_template: n8nDeployTemplateDoc
|
||||
n8n_deploy_template: n8nDeployTemplateDoc,
|
||||
n8n_manage_datatable: n8nManageDatatableDoc
|
||||
};
|
||||
|
||||
// Re-export types
|
||||
|
||||
@@ -10,3 +10,4 @@ export { n8nTestWorkflowDoc } from './n8n-test-workflow';
|
||||
export { n8nExecutionsDoc } from './n8n-executions';
|
||||
export { n8nWorkflowVersionsDoc } from './n8n-workflow-versions';
|
||||
export { n8nDeployTemplateDoc } from './n8n-deploy-template';
|
||||
export { n8nManageDatatableDoc } from './n8n-manage-datatable';
|
||||
|
||||
109
src/mcp/tool-docs/workflow_management/n8n-manage-datatable.ts
Normal file
109
src/mcp/tool-docs/workflow_management/n8n-manage-datatable.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { ToolDocumentation } from '../types';
|
||||
|
||||
export const n8nManageDatatableDoc: ToolDocumentation = {
|
||||
name: 'n8n_manage_datatable',
|
||||
category: 'workflow_management',
|
||||
essentials: {
|
||||
description: 'Manage n8n data tables and rows. Unified tool for table CRUD and row operations with filtering, pagination, and dry-run support.',
|
||||
keyParameters: ['action', 'tableId', 'name', 'data', 'filter'],
|
||||
example: 'n8n_manage_datatable({action: "createTable", name: "Contacts", columns: [{name: "email", type: "string"}]})',
|
||||
performance: 'Fast (100-500ms)',
|
||||
tips: [
|
||||
'Table actions: createTable, listTables, getTable, updateTable, deleteTable',
|
||||
'Row actions: getRows, insertRows, updateRows, upsertRows, deleteRows',
|
||||
'Use dryRun: true to preview update/upsert/delete before applying',
|
||||
'Filter supports: eq, neq, like, ilike, gt, gte, lt, lte conditions',
|
||||
'Use returnData: true to get affected rows back from update/upsert/delete',
|
||||
'Requires n8n enterprise or cloud with data tables feature'
|
||||
]
|
||||
},
|
||||
full: {
|
||||
description: `**Table Actions:**
|
||||
- **createTable**: Create a new data table with optional typed columns
|
||||
- **listTables**: List all data tables (paginated)
|
||||
- **getTable**: Get table details and column definitions by ID
|
||||
- **updateTable**: Rename an existing table
|
||||
- **deleteTable**: Permanently delete a table and all its rows
|
||||
|
||||
**Row Actions:**
|
||||
- **getRows**: List rows with filtering, sorting, search, and pagination
|
||||
- **insertRows**: Insert one or more rows (bulk)
|
||||
- **updateRows**: Update rows matching a filter condition
|
||||
- **upsertRows**: Update matching row or insert if none match
|
||||
- **deleteRows**: Delete rows matching a filter condition (filter required)
|
||||
|
||||
**Filter System:** Used in getRows, updateRows, upsertRows, deleteRows
|
||||
- Combine conditions with "and" (default) or "or"
|
||||
- Conditions: eq, neq, like, ilike, gt, gte, lt, lte
|
||||
- Example: {type: "and", filters: [{columnName: "status", condition: "eq", value: "active"}]}
|
||||
|
||||
**Dry Run:** updateRows, upsertRows, and deleteRows support dryRun: true to preview changes without applying them.`,
|
||||
parameters: {
|
||||
action: { type: 'string', required: true, description: 'Operation to perform' },
|
||||
tableId: { type: 'string', required: false, description: 'Data table ID (required for all except createTable and listTables)' },
|
||||
name: { type: 'string', required: false, description: 'For createTable/updateTable: table name' },
|
||||
columns: { type: 'array', required: false, description: 'For createTable: column definitions [{name, type?}]. Types: string, number, boolean, date' },
|
||||
data: { type: 'array|object', required: false, description: 'For insertRows: array of row objects. For updateRows/upsertRows: object with column values' },
|
||||
filter: { type: 'object', required: false, description: 'Filter: {type?: "and"|"or", filters: [{columnName, condition, value}]}' },
|
||||
limit: { type: 'number', required: false, description: 'For listTables/getRows: max results (1-100)' },
|
||||
cursor: { type: 'string', required: false, description: 'For listTables/getRows: pagination cursor' },
|
||||
sortBy: { type: 'string', required: false, description: 'For getRows: "columnName:asc" or "columnName:desc"' },
|
||||
search: { type: 'string', required: false, description: 'For getRows: full-text search across string columns' },
|
||||
returnType: { type: 'string', required: false, description: 'For insertRows: "count" (default), "id", or "all"' },
|
||||
returnData: { type: 'boolean', required: false, description: 'For updateRows/upsertRows/deleteRows: return affected rows (default: false)' },
|
||||
dryRun: { type: 'boolean', required: false, description: 'For updateRows/upsertRows/deleteRows: preview without applying (default: false)' },
|
||||
},
|
||||
returns: `Depends on action:
|
||||
- createTable: {id, name}
|
||||
- listTables: {tables, count, nextCursor?}
|
||||
- getTable: Full table object with columns
|
||||
- updateTable: Updated table object
|
||||
- deleteTable: Success message
|
||||
- getRows: {rows, count, nextCursor?}
|
||||
- insertRows: Depends on returnType (count/ids/rows)
|
||||
- updateRows: Update result with optional rows
|
||||
- upsertRows: Upsert result with action type
|
||||
- deleteRows: Delete result with optional rows`,
|
||||
examples: [
|
||||
'// Create a table\nn8n_manage_datatable({action: "createTable", name: "Contacts", columns: [{name: "email", type: "string"}, {name: "score", type: "number"}]})',
|
||||
'// List all tables\nn8n_manage_datatable({action: "listTables"})',
|
||||
'// Get table details\nn8n_manage_datatable({action: "getTable", tableId: "dt-123"})',
|
||||
'// Rename a table\nn8n_manage_datatable({action: "updateTable", tableId: "dt-123", name: "New Name"})',
|
||||
'// Delete a table\nn8n_manage_datatable({action: "deleteTable", tableId: "dt-123"})',
|
||||
'// Get rows with filter\nn8n_manage_datatable({action: "getRows", tableId: "dt-123", filter: {filters: [{columnName: "status", condition: "eq", value: "active"}]}, limit: 50})',
|
||||
'// Search rows\nn8n_manage_datatable({action: "getRows", tableId: "dt-123", search: "john", sortBy: "name:asc"})',
|
||||
'// Insert rows\nn8n_manage_datatable({action: "insertRows", tableId: "dt-123", data: [{email: "a@b.com", score: 10}], returnType: "all"})',
|
||||
'// Update rows (dry run)\nn8n_manage_datatable({action: "updateRows", tableId: "dt-123", filter: {filters: [{columnName: "score", condition: "lt", value: 5}]}, data: {status: "inactive"}, dryRun: true})',
|
||||
'// Upsert a row\nn8n_manage_datatable({action: "upsertRows", tableId: "dt-123", filter: {filters: [{columnName: "email", condition: "eq", value: "a@b.com"}]}, data: {score: 15}, returnData: true})',
|
||||
'// Delete rows\nn8n_manage_datatable({action: "deleteRows", tableId: "dt-123", filter: {filters: [{columnName: "status", condition: "eq", value: "deleted"}]}})',
|
||||
],
|
||||
useCases: [
|
||||
'Persist structured workflow data across executions',
|
||||
'Store and query lookup tables for workflow logic',
|
||||
'Bulk insert records from external data sources',
|
||||
'Conditionally update records matching criteria',
|
||||
'Upsert to maintain unique records by key column',
|
||||
'Clean up old or invalid rows with filtered delete',
|
||||
'Preview changes with dryRun before modifying data',
|
||||
],
|
||||
performance: 'Table operations: 50-300ms. Row operations: 100-500ms depending on data size and filters.',
|
||||
bestPractices: [
|
||||
'Define column types upfront for schema consistency',
|
||||
'Use dryRun: true before bulk updates/deletes to verify filter correctness',
|
||||
'Use returnType: "count" (default) for insertRows to minimize response size',
|
||||
'Use filter with specific conditions to avoid unintended bulk operations',
|
||||
'Use cursor-based pagination for large result sets',
|
||||
'Use sortBy for deterministic row ordering',
|
||||
],
|
||||
pitfalls: [
|
||||
'Requires N8N_API_URL and N8N_API_KEY configured',
|
||||
'Feature only available on n8n enterprise or cloud plans',
|
||||
'deleteTable permanently deletes all rows — cannot be undone',
|
||||
'deleteRows requires a filter — cannot delete all rows without one',
|
||||
'Column types cannot be changed after table creation via API',
|
||||
'updateTable can only rename the table (no column modifications via public API)',
|
||||
'projectId cannot be set via the public API — use the n8n UI',
|
||||
],
|
||||
relatedTools: ['n8n_create_workflow', 'n8n_list_workflows', 'n8n_health_check'],
|
||||
},
|
||||
};
|
||||
@@ -63,6 +63,10 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
executionTimeout: { type: 'number' },
|
||||
errorWorkflow: { type: 'string' }
|
||||
}
|
||||
},
|
||||
projectId: {
|
||||
type: 'string',
|
||||
description: 'Optional project ID to create the workflow in (enterprise feature)'
|
||||
}
|
||||
},
|
||||
required: ['name', 'nodes', 'connections']
|
||||
@@ -602,5 +606,52 @@ export const n8nManagementTools: ToolDefinition[] = [
|
||||
destructiveHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'n8n_manage_datatable',
|
||||
description: `Manage n8n data tables and rows. Actions: createTable, listTables, getTable, updateTable, deleteTable, getRows, insertRows, updateRows, upsertRows, deleteRows. Requires n8n enterprise/cloud with data tables feature.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['createTable', 'listTables', 'getTable', 'updateTable', 'deleteTable', 'getRows', 'insertRows', 'updateRows', 'upsertRows', 'deleteRows'],
|
||||
description: 'Operation to perform',
|
||||
},
|
||||
tableId: { type: 'string', description: 'Data table ID (required for all actions except createTable and listTables)' },
|
||||
name: { type: 'string', description: 'For createTable/updateTable: table name' },
|
||||
columns: {
|
||||
type: 'array',
|
||||
description: 'For createTable: column definitions',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] },
|
||||
},
|
||||
required: ['name'],
|
||||
},
|
||||
},
|
||||
data: { description: 'For insertRows: array of row objects. For updateRows/upsertRows: object with column values.' },
|
||||
filter: {
|
||||
type: 'object',
|
||||
description: 'For getRows/updateRows/upsertRows/deleteRows: {type?: "and"|"or", filters: [{columnName, condition, value}]}',
|
||||
},
|
||||
limit: { type: 'number', description: 'For listTables/getRows: max results (1-100)' },
|
||||
cursor: { type: 'string', description: 'For listTables/getRows: pagination cursor' },
|
||||
sortBy: { type: 'string', description: 'For getRows: "columnName:asc" or "columnName:desc"' },
|
||||
search: { type: 'string', description: 'For getRows: text search across string columns' },
|
||||
returnType: { type: 'string', enum: ['count', 'id', 'all'], description: 'For insertRows: what to return (default: count)' },
|
||||
returnData: { type: 'boolean', description: 'For updateRows/upsertRows/deleteRows: return affected rows (default: false)' },
|
||||
dryRun: { type: 'boolean', description: 'For updateRows/upsertRows/deleteRows: preview without applying (default: false)' },
|
||||
},
|
||||
required: ['action'],
|
||||
},
|
||||
annotations: {
|
||||
title: 'Manage Data Tables',
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
openWorldHint: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -22,6 +22,15 @@ import {
|
||||
SourceControlStatus,
|
||||
SourceControlPullResult,
|
||||
SourceControlPushResult,
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
DataTableListParams,
|
||||
DataTableRow,
|
||||
DataTableRowListParams,
|
||||
DataTableInsertRowsParams,
|
||||
DataTableUpdateRowsParams,
|
||||
DataTableUpsertRowParams,
|
||||
DataTableDeleteRowsParams,
|
||||
} from '../types/n8n-api';
|
||||
import { handleN8nApiError, logN8nError } from '../utils/n8n-errors';
|
||||
import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation';
|
||||
@@ -582,6 +591,95 @@ export class N8nApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async createDataTable(params: { name: string; columns?: DataTableColumn[] }): Promise<DataTable> {
|
||||
try {
|
||||
const response = await this.client.post('/data-tables', params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async listDataTables(params: DataTableListParams = {}): Promise<{ data: DataTable[]; nextCursor?: string | null }> {
|
||||
try {
|
||||
const response = await this.client.get('/data-tables', { params });
|
||||
return this.validateListResponse<DataTable>(response.data, 'data-tables');
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getDataTable(id: string): Promise<DataTable> {
|
||||
try {
|
||||
const response = await this.client.get(`/data-tables/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateDataTable(id: string, params: { name: string }): Promise<DataTable> {
|
||||
try {
|
||||
const response = await this.client.patch(`/data-tables/${id}`, params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDataTable(id: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/data-tables/${id}`);
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async getDataTableRows(id: string, params: DataTableRowListParams = {}): Promise<{ data: DataTableRow[]; nextCursor?: string | null }> {
|
||||
try {
|
||||
const response = await this.client.get(`/data-tables/${id}/rows`, { params });
|
||||
return this.validateListResponse<DataTableRow>(response.data, 'data-table-rows');
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async insertDataTableRows(id: string, params: DataTableInsertRowsParams): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.post(`/data-tables/${id}/rows`, params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateDataTableRows(id: string, params: DataTableUpdateRowsParams): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.patch(`/data-tables/${id}/rows/update`, params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async upsertDataTableRow(id: string, params: DataTableUpsertRowParams): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.post(`/data-tables/${id}/rows/upsert`, params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDataTableRows(id: string, params: DataTableDeleteRowsParams): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.delete(`/data-tables/${id}/rows/delete`, { params });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleN8nApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and normalizes n8n API list responses.
|
||||
* Handles both modern format {data: [], nextCursor?: string} and legacy array format.
|
||||
|
||||
@@ -454,4 +454,82 @@ export interface ErrorSuggestion {
|
||||
title: string;
|
||||
description: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
// Data Table types
|
||||
export interface DataTableColumn {
|
||||
name: string;
|
||||
type?: 'string' | 'number' | 'boolean' | 'date';
|
||||
}
|
||||
|
||||
export interface DataTableColumnResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'date';
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface DataTable {
|
||||
id: string;
|
||||
name: string;
|
||||
columns?: DataTableColumnResponse[];
|
||||
projectId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DataTableRow {
|
||||
id?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
[columnName: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DataTableFilterCondition {
|
||||
columnName: string;
|
||||
condition: 'eq' | 'neq' | 'like' | 'ilike' | 'gt' | 'gte' | 'lt' | 'lte';
|
||||
value?: any;
|
||||
}
|
||||
|
||||
export interface DataTableFilter {
|
||||
type?: 'and' | 'or';
|
||||
filters: DataTableFilterCondition[];
|
||||
}
|
||||
|
||||
export interface DataTableListParams {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
export interface DataTableRowListParams {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
filter?: string;
|
||||
sortBy?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface DataTableInsertRowsParams {
|
||||
data: Record<string, unknown>[];
|
||||
returnType?: 'count' | 'id' | 'all';
|
||||
}
|
||||
|
||||
export interface DataTableUpdateRowsParams {
|
||||
filter: DataTableFilter;
|
||||
data: Record<string, unknown>;
|
||||
returnData?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface DataTableUpsertRowParams {
|
||||
filter: DataTableFilter;
|
||||
data: Record<string, unknown>;
|
||||
returnData?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export interface DataTableDeleteRowsParams {
|
||||
filter: string;
|
||||
returnData?: boolean;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
@@ -22,8 +22,10 @@ export class N8nAuthenticationError extends N8nApiError {
|
||||
}
|
||||
|
||||
export class N8nNotFoundError extends N8nApiError {
|
||||
constructor(resource: string, id?: string) {
|
||||
const message = id ? `${resource} with ID ${id} not found` : `${resource} not found`;
|
||||
constructor(messageOrResource: string, id?: string) {
|
||||
// If id is provided, format as "resource with ID id not found"
|
||||
// Otherwise, use messageOrResource as-is (it's already a complete message from the API)
|
||||
const message = id ? `${messageOrResource} with ID ${id} not found` : messageOrResource;
|
||||
super(message, 404, 'NOT_FOUND');
|
||||
this.name = 'N8nNotFoundError';
|
||||
}
|
||||
@@ -70,7 +72,7 @@ export function handleN8nApiError(error: unknown): N8nApiError {
|
||||
case 401:
|
||||
return new N8nAuthenticationError(message);
|
||||
case 404:
|
||||
return new N8nNotFoundError('Resource', message);
|
||||
return new N8nNotFoundError(message || 'Resource');
|
||||
case 400:
|
||||
return new N8nValidationError(message, data);
|
||||
case 429:
|
||||
|
||||
728
tests/unit/mcp/handlers-manage-datatable.test.ts
Normal file
728
tests/unit/mcp/handlers-manage-datatable.test.ts
Normal file
@@ -0,0 +1,728 @@
|
||||
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: encodeURIComponent('name:asc'),
|
||||
search: 'john',
|
||||
});
|
||||
});
|
||||
|
||||
it('should serialize object filter to URL-encoded 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: encodeURIComponent(JSON.stringify(objectFilter)),
|
||||
});
|
||||
});
|
||||
|
||||
it('should URL-encode string filter', async () => {
|
||||
mockApiClient.getDataTableRows.mockResolvedValue({ data: [], nextCursor: null });
|
||||
|
||||
const filterStr = '{"type":"and","filters":[]}';
|
||||
await handlers.handleGetRows({
|
||||
tableId: 'dt-1',
|
||||
filter: filterStr,
|
||||
});
|
||||
|
||||
expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', {
|
||||
filter: encodeURIComponent(filterStr),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================================================
|
||||
// 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: encodeURIComponent(JSON.stringify({ type: 'and', ...filter })),
|
||||
});
|
||||
});
|
||||
|
||||
it('should URL-encode serialized filter 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: encodeURIComponent(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: encodeURIComponent(JSON.stringify({ type: 'and', ...filter })),
|
||||
dryRun: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,16 @@ describe('handlers-n8n-manager', () => {
|
||||
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(),
|
||||
};
|
||||
|
||||
// Setup mock repository
|
||||
@@ -631,6 +641,27 @@ describe('handlers-n8n-manager', () => {
|
||||
expect(result.details.errors[0]).toContain('Webhook');
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass projectId to API when provided', async () => {
|
||||
const testWorkflow = createTestWorkflow();
|
||||
const input = {
|
||||
name: 'Test Workflow',
|
||||
nodes: testWorkflow.nodes,
|
||||
connections: testWorkflow.connections,
|
||||
projectId: 'project-abc-123',
|
||||
};
|
||||
|
||||
mockApiClient.createWorkflow.mockResolvedValue(testWorkflow);
|
||||
|
||||
const result = await handlers.handleCreateWorkflow(input);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
projectId: 'project-abc-123',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGetWorkflow', () => {
|
||||
@@ -1081,10 +1112,10 @@ describe('handlers-n8n-manager', () => {
|
||||
enabled: true,
|
||||
},
|
||||
managementTools: {
|
||||
count: 13,
|
||||
count: 14,
|
||||
enabled: true,
|
||||
},
|
||||
totalAvailable: 20,
|
||||
totalAvailable: 21,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -542,6 +542,9 @@ describe('Parameter Validation', () => {
|
||||
await expect(server.testExecuteTool('n8n_test_workflow', {}))
|
||||
.rejects.toThrow('Missing required parameters for n8n_test_workflow: workflowId');
|
||||
|
||||
await expect(server.testExecuteTool('n8n_manage_datatable', {}))
|
||||
.rejects.toThrow('n8n_manage_datatable: Validation failed:\n • action: action is required');
|
||||
|
||||
for (const tool of n8nToolsWithRequiredParams) {
|
||||
await expect(server.testExecuteTool(tool.name, tool.args))
|
||||
.rejects.toThrow(tool.expected);
|
||||
|
||||
@@ -297,7 +297,7 @@ describe('N8nApiClient', () => {
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nNotFoundError);
|
||||
expect((err as N8nNotFoundError).message).toContain('not found');
|
||||
expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found');
|
||||
expect((err as N8nNotFoundError).statusCode).toBe(404);
|
||||
}
|
||||
});
|
||||
@@ -380,7 +380,7 @@ describe('N8nApiClient', () => {
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nNotFoundError);
|
||||
expect((err as N8nNotFoundError).message).toContain('not found');
|
||||
expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found');
|
||||
expect((err as N8nNotFoundError).statusCode).toBe(404);
|
||||
}
|
||||
});
|
||||
@@ -432,7 +432,7 @@ describe('N8nApiClient', () => {
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nNotFoundError);
|
||||
expect((err as N8nNotFoundError).message).toContain('not found');
|
||||
expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found');
|
||||
expect((err as N8nNotFoundError).statusCode).toBe(404);
|
||||
}
|
||||
});
|
||||
@@ -501,7 +501,7 @@ describe('N8nApiClient', () => {
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nNotFoundError);
|
||||
expect((err as N8nNotFoundError).message).toContain('not found');
|
||||
expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found');
|
||||
expect((err as N8nNotFoundError).statusCode).toBe(404);
|
||||
}
|
||||
});
|
||||
@@ -1278,7 +1278,7 @@ describe('N8nApiClient', () => {
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nNotFoundError);
|
||||
expect((err as N8nNotFoundError).message).toContain('not found');
|
||||
expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found');
|
||||
expect((err as N8nNotFoundError).statusCode).toBe(404);
|
||||
}
|
||||
});
|
||||
@@ -1300,6 +1300,363 @@ describe('N8nApiClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDataTable', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should create data table with name and columns', async () => {
|
||||
const params = {
|
||||
name: 'My Table',
|
||||
columns: [
|
||||
{ name: 'email', type: 'string' as const },
|
||||
{ name: 'count', type: 'number' as const },
|
||||
],
|
||||
};
|
||||
const createdTable = { id: 'dt-1', name: 'My Table', columns: [] };
|
||||
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: createdTable });
|
||||
|
||||
const result = await client.createDataTable(params);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/data-tables', params);
|
||||
expect(result).toEqual(createdTable);
|
||||
});
|
||||
|
||||
it('should create data table without columns', async () => {
|
||||
const params = { name: 'Empty Table' };
|
||||
const createdTable = { id: 'dt-2', name: 'Empty Table' };
|
||||
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: createdTable });
|
||||
|
||||
const result = await client.createDataTable(params);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/data-tables', params);
|
||||
expect(result).toEqual(createdTable);
|
||||
});
|
||||
|
||||
it('should handle 400 error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 400, data: { message: 'Invalid table name' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('post', error);
|
||||
|
||||
try {
|
||||
await client.createDataTable({ name: '' });
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nValidationError);
|
||||
expect((err as N8nValidationError).message).toBe('Invalid table name');
|
||||
expect((err as N8nValidationError).statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listDataTables', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should list data tables successfully', async () => {
|
||||
const response = { data: [{ id: 'dt-1', name: 'Table One' }], nextCursor: 'abc' };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: response });
|
||||
|
||||
const result = await client.listDataTables({ limit: 10, cursor: 'xyz' });
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/data-tables', { params: { limit: 10, cursor: 'xyz' } });
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('should handle error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 500, data: { message: 'Internal server error' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('get', error);
|
||||
|
||||
try {
|
||||
await client.listDataTables();
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nServerError);
|
||||
expect((err as N8nServerError).statusCode).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataTable', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should get data table successfully', async () => {
|
||||
const table = { id: 'dt-1', name: 'My Table', columns: [] };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: table });
|
||||
|
||||
const result = await client.getDataTable('dt-1');
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/data-tables/dt-1');
|
||||
expect(result).toEqual(table);
|
||||
});
|
||||
|
||||
it('should handle 404 error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 404, data: { message: 'Data table not found' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('get', error);
|
||||
|
||||
try {
|
||||
await client.getDataTable('dt-nonexistent');
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nNotFoundError);
|
||||
expect((err as N8nNotFoundError).statusCode).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDataTable', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should update data table successfully', async () => {
|
||||
const updated = { id: 'dt-1', name: 'Renamed' };
|
||||
mockAxiosInstance.patch.mockResolvedValue({ data: updated });
|
||||
|
||||
const result = await client.updateDataTable('dt-1', { name: 'Renamed' });
|
||||
|
||||
expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/data-tables/dt-1', { name: 'Renamed' });
|
||||
expect(result).toEqual(updated);
|
||||
});
|
||||
|
||||
it('should handle error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 400, data: { message: 'Invalid name' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('patch', error);
|
||||
|
||||
try {
|
||||
await client.updateDataTable('dt-1', { name: '' });
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nValidationError);
|
||||
expect((err as N8nValidationError).statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDataTable', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should delete data table successfully', async () => {
|
||||
mockAxiosInstance.delete.mockResolvedValue({ data: {} });
|
||||
|
||||
await client.deleteDataTable('dt-1');
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/data-tables/dt-1');
|
||||
});
|
||||
|
||||
it('should handle 404 error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 404, data: { message: 'Data table not found' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('delete', error);
|
||||
|
||||
try {
|
||||
await client.deleteDataTable('dt-nonexistent');
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nNotFoundError);
|
||||
expect((err as N8nNotFoundError).statusCode).toBe(404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDataTableRows', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should get data table rows with params', async () => {
|
||||
const response = { data: [{ id: 1, email: 'a@b.com' }], nextCursor: null };
|
||||
mockAxiosInstance.get.mockResolvedValue({ data: response });
|
||||
|
||||
const params = { limit: 50, sortBy: 'email:asc', search: 'john' };
|
||||
const result = await client.getDataTableRows('dt-1', params);
|
||||
|
||||
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/data-tables/dt-1/rows', { params });
|
||||
expect(result).toEqual(response);
|
||||
});
|
||||
|
||||
it('should handle error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 500, data: { message: 'Internal server error' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('get', error);
|
||||
|
||||
try {
|
||||
await client.getDataTableRows('dt-1');
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nServerError);
|
||||
expect((err as N8nServerError).statusCode).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertDataTableRows', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should insert data table rows successfully', async () => {
|
||||
const insertResult = { insertedCount: 2 };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: insertResult });
|
||||
|
||||
const params = { data: [{ email: 'a@b.com' }, { email: 'c@d.com' }], returnType: 'count' as const };
|
||||
const result = await client.insertDataTableRows('dt-1', params);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/data-tables/dt-1/rows', params);
|
||||
expect(result).toEqual(insertResult);
|
||||
});
|
||||
|
||||
it('should handle 400 error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 400, data: { message: 'Invalid row data' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('post', error);
|
||||
|
||||
try {
|
||||
await client.insertDataTableRows('dt-1', { data: [{}] });
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nValidationError);
|
||||
expect((err as N8nValidationError).message).toBe('Invalid row data');
|
||||
expect((err as N8nValidationError).statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDataTableRows', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should update data table rows successfully', async () => {
|
||||
const updateResult = { updatedCount: 3 };
|
||||
mockAxiosInstance.patch.mockResolvedValue({ data: updateResult });
|
||||
|
||||
const params = {
|
||||
filter: { type: 'and' as const, filters: [{ columnName: 'status', condition: 'eq' as const, value: 'old' }] },
|
||||
data: { status: 'new' },
|
||||
};
|
||||
const result = await client.updateDataTableRows('dt-1', params);
|
||||
|
||||
expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/data-tables/dt-1/rows/update', params);
|
||||
expect(result).toEqual(updateResult);
|
||||
});
|
||||
|
||||
it('should handle error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 500, data: { message: 'Internal server error' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('patch', error);
|
||||
|
||||
try {
|
||||
await client.updateDataTableRows('dt-1', {
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { name: 'test' },
|
||||
});
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nServerError);
|
||||
expect((err as N8nServerError).statusCode).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertDataTableRow', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should upsert data table row successfully', async () => {
|
||||
const upsertResult = { action: 'updated', row: { id: 1, email: 'a@b.com' } };
|
||||
mockAxiosInstance.post.mockResolvedValue({ data: upsertResult });
|
||||
|
||||
const params = {
|
||||
filter: { type: 'and' as const, filters: [{ columnName: 'email', condition: 'eq' as const, value: 'a@b.com' }] },
|
||||
data: { score: 15 },
|
||||
};
|
||||
const result = await client.upsertDataTableRow('dt-1', params);
|
||||
|
||||
expect(mockAxiosInstance.post).toHaveBeenCalledWith('/data-tables/dt-1/rows/upsert', params);
|
||||
expect(result).toEqual(upsertResult);
|
||||
});
|
||||
|
||||
it('should handle error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 400, data: { message: 'Invalid upsert params' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('post', error);
|
||||
|
||||
try {
|
||||
await client.upsertDataTableRow('dt-1', {
|
||||
filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] },
|
||||
data: { name: 'test' },
|
||||
});
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nValidationError);
|
||||
expect((err as N8nValidationError).statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDataTableRows', () => {
|
||||
beforeEach(() => {
|
||||
client = new N8nApiClient(defaultConfig);
|
||||
});
|
||||
|
||||
it('should delete data table rows successfully', async () => {
|
||||
const deleteResult = { deletedCount: 2 };
|
||||
mockAxiosInstance.delete.mockResolvedValue({ data: deleteResult });
|
||||
|
||||
const params = { filter: '{"type":"and","filters":[]}', dryRun: false };
|
||||
const result = await client.deleteDataTableRows('dt-1', params);
|
||||
|
||||
expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/data-tables/dt-1/rows/delete', { params });
|
||||
expect(result).toEqual(deleteResult);
|
||||
});
|
||||
|
||||
it('should handle error', async () => {
|
||||
const error = {
|
||||
message: 'Request failed',
|
||||
response: { status: 500, data: { message: 'Internal server error' } },
|
||||
};
|
||||
await mockAxiosInstance.simulateError('delete', error);
|
||||
|
||||
try {
|
||||
await client.deleteDataTableRows('dt-1', { filter: '{}' });
|
||||
expect.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(N8nServerError);
|
||||
expect((err as N8nServerError).statusCode).toBe(500);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('interceptors', () => {
|
||||
let requestInterceptor: any;
|
||||
let responseInterceptor: any;
|
||||
|
||||
Reference in New Issue
Block a user