mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
test: Add workflow api testing for webhooks (#18400)
This commit is contained in:
@@ -35,8 +35,9 @@ Troubleshooting:
|
|||||||
|
|
||||||
## 🏗️ Architecture Overview
|
## 🏗️ 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)
|
Tests (*.spec.ts)
|
||||||
↓ uses
|
↓ uses
|
||||||
@@ -47,11 +48,19 @@ Page Objects (*Page.ts) - UI interactions
|
|||||||
BasePage - Common utilities
|
BasePage - Common utilities
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### API Testing (Two-Layer Approach)
|
||||||
|
```
|
||||||
|
Tests (*.spec.ts)
|
||||||
|
↓ uses
|
||||||
|
API Services (ApiHelpers + specialized helpers)
|
||||||
|
```
|
||||||
|
|
||||||
### Core Principle: Separation of Concerns
|
### Core Principle: Separation of Concerns
|
||||||
- **BasePage**: Generic interaction methods
|
- **BasePage**: Generic interaction methods
|
||||||
- **Page Objects**: Element locators and simple actions
|
- **Page Objects**: Element locators and simple actions
|
||||||
- **Composables**: Complex business workflows
|
- **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
|
### When Writing Tests
|
||||||
|
|
||||||
|
#### UI Tests
|
||||||
```typescript
|
```typescript
|
||||||
// ✅ GOOD: From 1-workflows.spec.ts
|
// ✅ GOOD: From 1-workflows.spec.ts
|
||||||
test('should create a new workflow using add workflow button', async ({ n8n }) => {
|
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
|
## 🎯 Best Practices
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ test('Performance under constraints @cloud:trial', async ({ n8n, api }) => {
|
|||||||
- `base.ts`: Standard fixtures with worker-scoped containers
|
- `base.ts`: Standard fixtures with worker-scoped containers
|
||||||
- `cloud-only.ts`: Cloud resource testing with test-scoped containers only
|
- `cloud-only.ts`: Cloud resource testing with test-scoped containers only
|
||||||
- **pages**: Page Object Models for UI interactions
|
- **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.)
|
- **utils**: Utility functions (string manipulation, helpers, etc.)
|
||||||
- **workflows**: Test workflow JSON files for import/reuse
|
- **workflows**: Test workflow JSON files for import/reuse
|
||||||
|
|
||||||
|
|||||||
@@ -146,8 +146,9 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
await use(n8nInstance);
|
await use(n8nInstance);
|
||||||
},
|
},
|
||||||
|
|
||||||
api: async ({ context }, use) => {
|
api: async ({ context }, use, testInfo) => {
|
||||||
const api = new ApiHelpers(context.request);
|
const api = new ApiHelpers(context.request);
|
||||||
|
await api.setupFromTags(testInfo.tags);
|
||||||
await use(api);
|
await use(api);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
INSTANCE_ADMIN_CREDENTIALS,
|
INSTANCE_ADMIN_CREDENTIALS,
|
||||||
} from '../config/test-users';
|
} from '../config/test-users';
|
||||||
import { TestError } from '../Types';
|
import { TestError } from '../Types';
|
||||||
|
import { WorkflowApiHelper } from './workflow-api-helper';
|
||||||
|
|
||||||
export interface LoginResponseData {
|
export interface LoginResponseData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,10 +31,12 @@ const DB_TAGS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export class ApiHelpers {
|
export class ApiHelpers {
|
||||||
private request: APIRequestContext;
|
request: APIRequestContext;
|
||||||
|
workflowApi: WorkflowApiHelper;
|
||||||
|
|
||||||
constructor(requestContext: APIRequestContext) {
|
constructor(requestContext: APIRequestContext) {
|
||||||
this.request = requestContext;
|
this.request = requestContext;
|
||||||
|
this.workflowApi = new WorkflowApiHelper(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== MAIN SETUP METHODS =====
|
// ===== 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