230 lines
7.2 KiB
TypeScript
230 lines
7.2 KiB
TypeScript
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
|
import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType} from '@plunk/db';
|
|
import {WorkflowExecutionService} from '../WorkflowExecutionService';
|
|
import {factories, getPrismaClient} from '../../../../../test/helpers';
|
|
|
|
/**
|
|
* Tests for WEBHOOK step config templating.
|
|
*
|
|
* `executeWebhook` is a private static method but is invokable at runtime
|
|
* through a `as any` cast. We mock `safeFetch` (also private) via the
|
|
* same mechanism so we can capture the rendered request without making a
|
|
* real network call.
|
|
*/
|
|
describe('WorkflowExecutionService.executeWebhook templating', () => {
|
|
let projectId: string;
|
|
const prisma = getPrismaClient();
|
|
|
|
// Capture (url, options) passed to safeFetch
|
|
let safeFetchSpy: ReturnType<typeof vi.spyOn>;
|
|
let captured: {url: string; options: RequestInit} | null = null;
|
|
|
|
beforeEach(async () => {
|
|
const {project} = await factories.createUserWithProject();
|
|
projectId = project.id;
|
|
|
|
captured = null;
|
|
safeFetchSpy = vi
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
.spyOn(WorkflowExecutionService as any, 'safeFetch')
|
|
.mockImplementation(async (...args: unknown[]) => {
|
|
const [url, options] = args as [string, RequestInit];
|
|
captured = {url, options};
|
|
return new Response('{"ok":true}', {
|
|
status: 200,
|
|
headers: {'Content-Type': 'application/json'},
|
|
});
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
safeFetchSpy.mockRestore();
|
|
});
|
|
|
|
/**
|
|
* Helper: build a workflow with a single WEBHOOK step using the given
|
|
* config, plus a contact and a RUNNING execution. Returns the args
|
|
* shape `executeWebhook` expects.
|
|
*/
|
|
async function setup(
|
|
webhookConfig: Record<string, unknown>,
|
|
contactOverrides: {data?: Record<string, unknown>} = {},
|
|
executionContext: Record<string, unknown> = {},
|
|
) {
|
|
const contact = await factories.createContact({
|
|
projectId,
|
|
data: contactOverrides.data,
|
|
});
|
|
|
|
const workflow = await factories.createWorkflow({
|
|
projectId,
|
|
enabled: true,
|
|
triggerType: WorkflowTriggerType.EVENT,
|
|
triggerConfig: {eventName: 'test.event'},
|
|
});
|
|
|
|
const step = await prisma.workflowStep.create({
|
|
data: {
|
|
workflowId: workflow.id,
|
|
type: WorkflowStepType.WEBHOOK,
|
|
name: 'Webhook',
|
|
position: {x: 0, y: 0},
|
|
config: webhookConfig,
|
|
},
|
|
});
|
|
|
|
const execution = await prisma.workflowExecution.create({
|
|
data: {
|
|
workflowId: workflow.id,
|
|
contactId: contact.id,
|
|
status: WorkflowExecutionStatus.RUNNING,
|
|
context: executionContext,
|
|
},
|
|
include: {contact: true, workflow: true},
|
|
});
|
|
|
|
const stepExecution = await prisma.workflowStepExecution.create({
|
|
data: {
|
|
executionId: execution.id,
|
|
stepId: step.id,
|
|
status: StepExecutionStatus.RUNNING,
|
|
startedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return {step, execution, stepExecution};
|
|
}
|
|
|
|
async function invokeWebhook(
|
|
step: unknown,
|
|
execution: unknown,
|
|
stepExecution: unknown,
|
|
config: unknown,
|
|
) {
|
|
// Call through `as any` because executeWebhook is private at the
|
|
// TypeScript level. JS has no actual access control.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return (WorkflowExecutionService as any).executeWebhook(step, execution, stepExecution, config);
|
|
}
|
|
|
|
it('renders {{vars}} in the URL from contact.data', async () => {
|
|
const {step, execution, stepExecution} = await setup(
|
|
{
|
|
url: 'https://example.com/api/users/{{userId}}',
|
|
method: 'GET',
|
|
},
|
|
{data: {userId: 'abc-123'}},
|
|
);
|
|
|
|
await invokeWebhook(step, execution, stepExecution, step.config);
|
|
|
|
expect(captured).not.toBeNull();
|
|
expect(captured!.url).toBe('https://example.com/api/users/abc-123');
|
|
});
|
|
|
|
it('renders {{vars}} in header values', async () => {
|
|
const {step, execution, stepExecution} = await setup(
|
|
{
|
|
url: 'https://example.com/hook',
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: 'Bearer {{apiToken}}',
|
|
'X-Static': 'literal',
|
|
},
|
|
},
|
|
{data: {apiToken: 'secret-token-xyz'}},
|
|
);
|
|
|
|
await invokeWebhook(step, execution, stepExecution, step.config);
|
|
|
|
expect(captured).not.toBeNull();
|
|
const headers = captured!.options.headers as Record<string, string>;
|
|
expect(headers.Authorization).toBe('Bearer secret-token-xyz');
|
|
expect(headers['X-Static']).toBe('literal');
|
|
});
|
|
|
|
it('renders {{vars}} in nested object body leaves and JSON-encodes', async () => {
|
|
const {step, execution, stepExecution} = await setup(
|
|
{
|
|
url: 'https://example.com/hook',
|
|
method: 'POST',
|
|
body: {
|
|
user: {
|
|
email: '{{email}}',
|
|
name: '{{firstName}}',
|
|
},
|
|
ref: 'literal-ref',
|
|
tags: ['plan:{{plan}}', 'static'],
|
|
},
|
|
},
|
|
{data: {firstName: 'Ada', plan: 'gold'}},
|
|
{campaignId: 'camp-9'},
|
|
);
|
|
|
|
await invokeWebhook(step, execution, stepExecution, step.config);
|
|
|
|
expect(captured).not.toBeNull();
|
|
const body = JSON.parse(captured!.options.body as string);
|
|
expect(body.user.email).toBe(execution.contact.email);
|
|
expect(body.user.name).toBe('Ada');
|
|
expect(body.ref).toBe('literal-ref');
|
|
expect(body.tags).toEqual(['plan:gold', 'static']);
|
|
});
|
|
|
|
it('leaves non-string body leaves untouched', async () => {
|
|
const {step, execution, stepExecution} = await setup({
|
|
url: 'https://example.com/hook',
|
|
method: 'POST',
|
|
body: {
|
|
score: 42,
|
|
active: true,
|
|
deleted: null,
|
|
meta: {
|
|
count: 7,
|
|
enabled: false,
|
|
},
|
|
tags: ['{{plan ?? free}}', 100, false],
|
|
},
|
|
});
|
|
|
|
await invokeWebhook(step, execution, stepExecution, step.config);
|
|
|
|
expect(captured).not.toBeNull();
|
|
const body = JSON.parse(captured!.options.body as string);
|
|
expect(body.score).toBe(42);
|
|
expect(body.active).toBe(true);
|
|
expect(body.deleted).toBe(null);
|
|
expect(body.meta).toEqual({count: 7, enabled: false});
|
|
// String leaf rendered (with default), non-string leaves preserved.
|
|
expect(body.tags).toEqual(['free', 100, false]);
|
|
});
|
|
|
|
it('renders {{event.*}} variables from the trigger payload', async () => {
|
|
const {step, execution, stepExecution} = await setup(
|
|
{
|
|
url: 'https://example.com/hooks/{{event.referrer}}',
|
|
method: 'POST',
|
|
headers: {
|
|
'X-Email-Id': '{{event.emailId}}',
|
|
},
|
|
body: {
|
|
referrer: '{{event.referrer}}',
|
|
subject: '{{event.subject}}',
|
|
},
|
|
},
|
|
{},
|
|
{referrer: 'newsletter-may', emailId: 'eml_abc123', subject: 'Welcome'},
|
|
);
|
|
|
|
await invokeWebhook(step, execution, stepExecution, step.config);
|
|
|
|
expect(captured).not.toBeNull();
|
|
expect(captured!.url).toBe('https://example.com/hooks/newsletter-may');
|
|
const headers = captured!.options.headers as Record<string, string>;
|
|
expect(headers['X-Email-Id']).toBe('eml_abc123');
|
|
const body = JSON.parse(captured!.options.body as string);
|
|
expect(body.referrer).toBe('newsletter-may');
|
|
expect(body.subject).toBe('Welcome');
|
|
});
|
|
});
|