mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
test: Add workflow api testing for webhooks (#18400)
This commit is contained in:
@@ -35,8 +35,9 @@ Troubleshooting:
|
||||
|
||||
## 🏗️ Architecture Overview
|
||||
|
||||
Our test architecture follows a strict four-layer approach:
|
||||
Our test architecture supports both UI-driven and API-driven testing:
|
||||
|
||||
### UI Testing (Four-Layer Approach)
|
||||
```
|
||||
Tests (*.spec.ts)
|
||||
↓ uses
|
||||
@@ -47,11 +48,19 @@ Page Objects (*Page.ts) - UI interactions
|
||||
BasePage - Common utilities
|
||||
```
|
||||
|
||||
### API Testing (Two-Layer Approach)
|
||||
```
|
||||
Tests (*.spec.ts)
|
||||
↓ uses
|
||||
API Services (ApiHelpers + specialized helpers)
|
||||
```
|
||||
|
||||
### Core Principle: Separation of Concerns
|
||||
- **BasePage**: Generic interaction methods
|
||||
- **Page Objects**: Element locators and simple actions
|
||||
- **Composables**: Complex business workflows
|
||||
- **Tests**: Readable scenarios using composables
|
||||
- **API Services**: REST API interactions, workflow management
|
||||
- **Tests**: Readable scenarios using composables or API services
|
||||
|
||||
---
|
||||
|
||||
@@ -293,6 +302,7 @@ export class ProjectComposer {
|
||||
|
||||
### When Writing Tests
|
||||
|
||||
#### UI Tests
|
||||
```typescript
|
||||
// ✅ GOOD: From 1-workflows.spec.ts
|
||||
test('should create a new workflow using add workflow button', async ({ n8n }) => {
|
||||
@@ -326,6 +336,29 @@ test('should enter debug mode for failed executions', async ({ n8n }) => {
|
||||
});
|
||||
```
|
||||
|
||||
#### API Tests
|
||||
```typescript
|
||||
// ✅ GOOD: API-driven workflow testing
|
||||
test('should create workflow via API, activate it, trigger webhook externally @auth:owner', async ({ api }) => {
|
||||
const workflowDefinition = JSON.parse(
|
||||
readFileSync(resolveFromRoot('workflows', 'simple-webhook-test.json'), 'utf8'),
|
||||
);
|
||||
|
||||
const createdWorkflow = await api.workflowApi.createWorkflow(workflowDefinition);
|
||||
await api.workflowApi.setActive(createdWorkflow.id, true);
|
||||
|
||||
const testPayload = { message: 'Hello from Playwright test' };
|
||||
const webhookResponse = await api.workflowApi.triggerWebhook('test-webhook', { data: testPayload });
|
||||
expect(webhookResponse.ok()).toBe(true);
|
||||
|
||||
const execution = await api.workflowApi.waitForExecution(createdWorkflow.id, 10000);
|
||||
expect(execution.status).toBe('success');
|
||||
|
||||
const executionDetails = await api.workflowApi.getExecution(execution.id);
|
||||
expect(executionDetails.data).toContain('Hello from Playwright test');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Best Practices
|
||||
|
||||
@@ -69,7 +69,7 @@ test('Performance under constraints @cloud:trial', async ({ n8n, api }) => {
|
||||
- `base.ts`: Standard fixtures with worker-scoped containers
|
||||
- `cloud-only.ts`: Cloud resource testing with test-scoped containers only
|
||||
- **pages**: Page Object Models for UI interactions
|
||||
- **services**: API helpers for E2E controller, REST calls, etc.
|
||||
- **services**: API helpers for E2E controller, REST calls, workflow management, etc.
|
||||
- **utils**: Utility functions (string manipulation, helpers, etc.)
|
||||
- **workflows**: Test workflow JSON files for import/reuse
|
||||
|
||||
|
||||
@@ -146,8 +146,9 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use(n8nInstance);
|
||||
},
|
||||
|
||||
api: async ({ context }, use) => {
|
||||
api: async ({ context }, use, testInfo) => {
|
||||
const api = new ApiHelpers(context.request);
|
||||
await api.setupFromTags(testInfo.tags);
|
||||
await use(api);
|
||||
},
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
INSTANCE_ADMIN_CREDENTIALS,
|
||||
} from '../config/test-users';
|
||||
import { TestError } from '../Types';
|
||||
import { WorkflowApiHelper } from './workflow-api-helper';
|
||||
|
||||
export interface LoginResponseData {
|
||||
id: string;
|
||||
@@ -30,10 +31,12 @@ const DB_TAGS = {
|
||||
} as const;
|
||||
|
||||
export class ApiHelpers {
|
||||
private request: APIRequestContext;
|
||||
request: APIRequestContext;
|
||||
workflowApi: WorkflowApiHelper;
|
||||
|
||||
constructor(requestContext: APIRequestContext) {
|
||||
this.request = requestContext;
|
||||
this.workflowApi = new WorkflowApiHelper(this);
|
||||
}
|
||||
|
||||
// ===== MAIN SETUP METHODS =====
|
||||
|
||||
124
packages/testing/playwright/services/workflow-api-helper.ts
Normal file
124
packages/testing/playwright/services/workflow-api-helper.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { ApiHelpers } from './api-helper';
|
||||
import { TestError } from '../Types';
|
||||
|
||||
export class WorkflowApiHelper {
|
||||
constructor(private api: ApiHelpers) {}
|
||||
|
||||
async createWorkflow(workflow: object) {
|
||||
const response = await this.api.request.post('/rest/workflows', { data: workflow });
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new TestError(`Failed to create workflow: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data ?? result;
|
||||
}
|
||||
|
||||
async setActive(workflowId: string, active: boolean) {
|
||||
const response = await this.api.request.patch(`/rest/workflows/${workflowId}`, {
|
||||
data: { active },
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new TestError(
|
||||
`Failed to ${active ? 'activate' : 'deactivate'} workflow: ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getExecutions(workflowId?: string, limit = 20) {
|
||||
const params = new URLSearchParams();
|
||||
if (workflowId) params.set('workflowId', workflowId);
|
||||
params.set('limit', limit.toString());
|
||||
|
||||
const response = await this.api.request.get('/rest/executions', { params });
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new TestError(`Failed to get executions: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (Array.isArray(result)) return result;
|
||||
if (result.data?.results) return result.data.results;
|
||||
if (result.data) return result.data;
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async getExecution(executionId: string) {
|
||||
const response = await this.api.request.get(`/rest/executions/${executionId}`);
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new TestError(`Failed to get execution: ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.data ?? result;
|
||||
}
|
||||
|
||||
async waitForExecution(workflowId: string, timeoutMs = 10000) {
|
||||
const initialExecutions = await this.getExecutions(workflowId, 50);
|
||||
const initialCount = initialExecutions.length;
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < timeoutMs) {
|
||||
const executions = await this.getExecutions(workflowId, 50);
|
||||
|
||||
if (executions.length > initialCount) {
|
||||
for (const execution of executions.slice(0, executions.length - initialCount)) {
|
||||
if (execution.status === 'success' || execution.status === 'error') {
|
||||
return execution;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const execution of executions) {
|
||||
const isCompleted = execution.status === 'success' || execution.status === 'error';
|
||||
if (isCompleted && execution.mode === 'webhook') {
|
||||
const executionTime = new Date(execution.startedAt ?? execution.createdAt).getTime();
|
||||
if (executionTime >= startTime - 5000) {
|
||||
return execution;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
|
||||
throw new TestError(`Execution did not complete within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
async triggerWebhook(
|
||||
path: string,
|
||||
options: { method?: 'GET' | 'POST'; data?: object; params?: Record<string, string> } = {},
|
||||
) {
|
||||
const { method = 'POST', data, params } = options;
|
||||
|
||||
let url = `/webhook/${path}`;
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const requestOptions: Record<string, unknown> = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
|
||||
if (data && method === 'POST') {
|
||||
requestOptions.data = data;
|
||||
}
|
||||
|
||||
const response =
|
||||
method === 'GET'
|
||||
? await this.api.request.get(url)
|
||||
: await this.api.request.post(url, requestOptions);
|
||||
|
||||
if (!response.ok()) {
|
||||
throw new TestError(`Webhook trigger failed: ${await response.text()}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
import { test, expect } from '../../fixtures/base';
|
||||
import { resolveFromRoot } from '../../utils/path-helper';
|
||||
|
||||
test.describe('External Webhook Triggering @auth:owner', () => {
|
||||
test('should create workflow via API, activate it, trigger webhook externally, and verify execution', async ({
|
||||
api,
|
||||
}) => {
|
||||
const workflowDefinition = JSON.parse(
|
||||
readFileSync(resolveFromRoot('workflows', 'simple-webhook-test.json'), 'utf8'),
|
||||
);
|
||||
|
||||
const createdWorkflow = await api.workflowApi.createWorkflow(workflowDefinition);
|
||||
expect(createdWorkflow.id).toBeDefined();
|
||||
|
||||
const workflowId = createdWorkflow.id;
|
||||
await api.workflowApi.setActive(workflowId, true);
|
||||
|
||||
const testPayload = { message: 'Hello from Playwright test' };
|
||||
|
||||
const webhookResponse = await api.workflowApi.triggerWebhook('test-webhook', {
|
||||
data: testPayload,
|
||||
});
|
||||
expect(webhookResponse.ok()).toBe(true);
|
||||
|
||||
const execution = await api.workflowApi.waitForExecution(workflowId, 10000);
|
||||
expect(execution.status).toBe('success');
|
||||
|
||||
const executionDetails = await api.workflowApi.getExecution(execution.id);
|
||||
expect(executionDetails.data).toContain('Hello from Playwright test');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "Simple Webhook Test",
|
||||
"active": false,
|
||||
"nodes": [
|
||||
{
|
||||
"parameters": {
|
||||
"httpMethod": "POST",
|
||||
"path": "test-webhook",
|
||||
"options": {}
|
||||
},
|
||||
"id": "webhook-trigger",
|
||||
"name": "Webhook",
|
||||
"type": "n8n-nodes-base.webhook",
|
||||
"typeVersion": 1,
|
||||
"position": [300, 300],
|
||||
"webhookId": "test-webhook-id"
|
||||
},
|
||||
{
|
||||
"parameters": {
|
||||
"assignments": {
|
||||
"assignments": [
|
||||
{
|
||||
"id": "result-field",
|
||||
"name": "result",
|
||||
"value": "=Webhook received: {{ $json.body }}",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"id": "timestamp-field",
|
||||
"name": "timestamp",
|
||||
"value": "={{ new Date().toISOString() }}",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"options": {}
|
||||
},
|
||||
"id": "set-response",
|
||||
"name": "Set Response",
|
||||
"type": "n8n-nodes-base.set",
|
||||
"typeVersion": 3.3,
|
||||
"position": [500, 300]
|
||||
}
|
||||
],
|
||||
"connections": {
|
||||
"Webhook": {
|
||||
"main": [
|
||||
[
|
||||
{
|
||||
"node": "Set Response",
|
||||
"type": "main",
|
||||
"index": 0
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"executionOrder": "v1"
|
||||
},
|
||||
"staticData": null,
|
||||
"meta": null,
|
||||
"pinData": {},
|
||||
"versionId": null,
|
||||
"triggerCount": 0,
|
||||
"tags": []
|
||||
}
|
||||
Reference in New Issue
Block a user