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; 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, contactOverrides: {data?: Record} = {}, executionContext: Record = {}, ) { 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; 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; 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'); }); });