From e70ddd36b8e9558dc44fc118277539fe6e7425d7 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:57:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(workflows):=20test-fire=20=E2=80=94=20safe?= =?UTF-8?q?=20dry-run=20of=20any=20flow=20on=20demand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine testRun() walks the whole graph immediately: waits pass through, gates auto-confirm, side-effecting actions short-circuit to {dryRun, would} so no real emails go out. POST /admin/workflows/:id/test-run returns the run status + per-node step log. Admin list gets a flask button that opens a result modal with an optional entity id (e.g. invoice) for conditions. --- .../integration/workflowEngine.test.js | 28 +++++++++ backend/src/routes/adminWorkflows.js | 22 +++++++ backend/src/services/workflows/actions.js | 3 + backend/src/services/workflows/engine.js | 46 ++++++++++++++ frontend/src/i18n/locales/de.json | 8 +++ frontend/src/i18n/locales/en.json | 8 +++ .../admin/workflows/WorkflowsListPage.tsx | 61 ++++++++++++++++++- frontend/src/services/workflows.service.ts | 19 ++++++ 8 files changed, 192 insertions(+), 3 deletions(-) diff --git a/backend/__tests__/integration/workflowEngine.test.js b/backend/__tests__/integration/workflowEngine.test.js index 34bbab6b..3aa747dd 100644 --- a/backend/__tests__/integration/workflowEngine.test.js +++ b/backend/__tests__/integration/workflowEngine.test.js @@ -315,4 +315,32 @@ describe('workflow engine', () => { const run = await db('workflow_runs').where({ id: run0.id }).first(); expect(run.status).toBe('failed'); }); + + test('testRun dry-run walks the whole flow (waits skipped, gate auto-confirmed, actions mocked)', async () => { + const wfId = await makeWorkflow({ + trigger: 'testfire.event', + nodes: [ + { key: 't', type: 'trigger' }, + { key: 'w', type: 'wait', config: { delayDays: 14 } }, + { key: 'g', type: 'gate', config: { type: 'payment_confirm' } }, + { key: 'a', type: 'action', config: { action: 'send_email', recipientClass: 'customer' } }, + { key: 'end', type: 'action', config: { action: 'noop' } }, + ], + edges: [ + { from: 't', to: 'w' }, + { from: 'w', to: 'g' }, + { from: 'g', handle: 'confirm', to: 'a' }, + { from: 'g', handle: 'deny', to: 'end' }, + { from: 'a', to: 'end' }, + ], + }); + const runId = await engine.testRun(wfId, { dryRun: true }); + const run = await db('workflow_runs').where({ id: runId }).first(); + expect(run.status).toBe('done'); // walked to completion — no parking at the wait/gate + + const steps = await db('workflow_run_steps').where({ run_id: runId }); + expect(steps.find((s) => s.node_key === 'w').status).toBe('skipped'); // wait passed through + const emailStep = steps.find((s) => s.node_key === 'a'); + expect(JSON.parse(emailStep.result).dryRun).toBe(true); // send_email mocked, no real mail + }); }); diff --git a/backend/src/routes/adminWorkflows.js b/backend/src/routes/adminWorkflows.js index 325af114..1cb8300f 100644 --- a/backend/src/routes/adminWorkflows.js +++ b/backend/src/routes/adminWorkflows.js @@ -96,6 +96,28 @@ router.get('/:id/runs', requirePermission('workflows.view'), async (req, res, ne } catch (e) { next(e); } }); +// Test-fire: run the workflow on demand (default dry-run — side effects mocked, +// waits skipped, gates auto-confirm) and return the step-by-step log. +router.post('/:id/test-run', requirePermission('workflows.manage'), async (req, res, next) => { + try { + const { entityType, entityId, payload, dryRun } = req.body || {}; + const runId = await workflows.testRun(Number(req.params.id), { + entityType: entityType || null, + entityId: entityId != null && entityId !== '' ? Number(entityId) : null, + payload: payload && typeof payload === 'object' ? payload : {}, + dryRun: dryRun !== false, // default true (safe) + }); + const run = await db('workflow_runs').where({ id: runId }).first(); + const steps = await db('workflow_run_steps').where({ run_id: runId }).orderBy('id', 'asc'); + res.json({ + runId, + dryRun: dryRun !== false, + status: run?.status, + steps: steps.map((s) => ({ ...s, result: parseJson(s.result, null) })), + }); + } catch (e) { next(e); } +}); + // --- List / get --- router.get('/', requirePermission('workflows.view'), async (req, res, next) => { try { diff --git a/backend/src/services/workflows/actions.js b/backend/src/services/workflows/actions.js index 5d4b6eb0..b205ae36 100644 --- a/backend/src/services/workflows/actions.js +++ b/backend/src/services/workflows/actions.js @@ -48,6 +48,7 @@ registry.registerCondition('invoice_paid', async (ctx) => { // anything else (customer/external) respects the business-hours floor. registry.registerAction('send_email', async (ctx) => { const cfg = ctx.node.config || {}; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'send_email', recipientClass: cfg.recipientClass || cfg.recipient || 'customer', emailType: cfg.emailType || cfg.template }; const recipientClass = cfg.recipientClass || cfg.recipient || 'customer'; const isInternal = recipientClass === 'admin' || recipientClass === 'internal'; const to = cfg.to @@ -75,6 +76,7 @@ registry.registerAction('send_email', async (ctx) => { registry.registerAction('queue_payment_check', async (ctx) => { const id = ctx.run.entity_id; if (!id) return { skipped: true, reason: 'no invoice entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'queue_payment_check', invoiceId: id }; await require('../invoiceService').queuePaymentCheckEmail(id); return { payment_check_queued: id }; }); @@ -87,6 +89,7 @@ registry.registerAction('queue_payment_check', async (ctx) => { registry.registerAction('escalate_to_collections', async (ctx) => { const id = ctx.run.entity_id; if (!id) return { skipped: true, reason: 'no invoice entity' }; + if (ctx.vars?.__dryRun) return { dryRun: true, would: 'escalate_to_collections', invoiceId: id }; const { db } = ctx; const invoice = await db('invoices').where({ id }).first(); if (!invoice) return { skipped: true, reason: 'invoice not found' }; diff --git a/backend/src/services/workflows/engine.js b/backend/src/services/workflows/engine.js index 584bb29f..4bc86408 100644 --- a/backend/src/services/workflows/engine.js +++ b/backend/src/services/workflows/engine.js @@ -144,6 +144,14 @@ async function advanceRun(runId) { break; } case 'wait': { + // Dry-run (test-fire): don't park — pass straight through so the whole + // flow runs in one shot, recording what it WOULD have waited for. + if (context.vars.__dryRun) { + const e = outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'skipped', { dryRun: true, wouldWaitUntil: computeWakeAt(node.config, context.vars) }); + break; + } const wakeAt = computeWakeAt(node.config, context.vars); await db('workflow_runs').where({ id: runId }) .update({ status: 'waiting', wake_at: wakeAt, current_node: currentKey, context: JSON.stringify(context) }); @@ -151,6 +159,14 @@ async function advanceRun(runId) { return; // paused — scheduler resumes when wake_at passes } case 'gate': { + // Dry-run (test-fire): auto-take the 'confirm' path so the escalation + // is exercised end-to-end, without creating an approval / emailing. + if (context.vars.__dryRun) { + const e = outEdge(edges, currentKey, 'confirm') || outEdge(edges, currentKey, null); + nextKey = e ? e.to_node : null; + await recordStep(runId, node, 'skipped', { dryRun: true, gateAutoConfirm: true }); + break; + } await db('workflow_runs').where({ id: runId }) .update({ status: 'waiting', wake_at: gateTimeout(node.config), current_node: currentKey, context: JSON.stringify(context) }); await recordStep(runId, node, 'waiting', { gate: true }); @@ -364,10 +380,40 @@ async function recoverStaleRuns({ staleMs = RECOVERY_STALE_MS, limit = 50 } = {} } } +/** + * Test-fire a workflow on demand (admin testing). Creates a run for the given + * entity/payload and starts it. Defaults to dryRun: side-effecting actions are + * mocked, waits pass through, and gates auto-take 'confirm' — so the WHOLE flow + * runs in one shot and the step log shows exactly what it would do, without + * sending real customer mail or charging fees. + */ +async function testRun(workflowId, { entityType = null, entityId = null, payload = {}, dryRun = true } = {}) { + const wf = await db('workflows').where({ id: workflowId }).first(); + if (!wf) throw new Error('Workflow not found'); + const vars = { ...(payload || {}), __test: true }; + if (dryRun) vars.__dryRun = true; + const dedupKey = `test:${workflowId}:${Date.now()}:${Math.round(Math.random() * 1e9)}`; + await db('workflow_runs').insert({ + workflow_id: wf.id, + version: wf.version, + trigger_event: `test:${wf.trigger_type}`, + entity_type: entityType, + entity_id: entityId, + status: 'pending', + context: JSON.stringify({ vars }), + dedup_key: dedupKey, + updated_at: db.fn.now(), + }); + const row = await db('workflow_runs').where({ dedup_key: dedupKey }).first(); + await startRun(row.id); + return row.id; +} + module.exports = { emitWorkflowEvent, runDueWaits, recoverStaleRuns, + testRun, startRun, advanceRun, resumeRun, diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 5ecea9f6..2bf9925d 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -212,6 +212,14 @@ "enabled": "Aktiv", "disabled": "Inaktiv", "confirmDelete": "Diesen Workflow löschen?", + "test": { + "title": "Testlauf", + "hint": "Probelauf: durchläuft den ganzen Ablauf sofort (Wartezeiten übersprungen, Gates automatisch bestätigt), Nebeneffekte werden nur simuliert – keine echten E-Mails. Optional eine Entitäts-ID (z. B. eine Rechnung) angeben, damit Bedingungen sie lesen können.", + "entityId": "Entitäts-ID (optional, z. B. Rechnungs-ID)", + "run": "Probelauf starten", + "result": "Ergebnis", + "failed": "Testlauf fehlgeschlagen" + }, "toast": { "createFailed": "Workflow konnte nicht erstellt werden", "deleted": "Workflow gelöscht", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 49948527..4db1229e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -212,6 +212,14 @@ "enabled": "Enabled", "disabled": "Disabled", "confirmDelete": "Delete this workflow?", + "test": { + "title": "Test run", + "hint": "Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.", + "entityId": "Entity id (optional, e.g. invoice id)", + "run": "Run dry test", + "result": "Result", + "failed": "Test run failed" + }, "toast": { "createFailed": "Could not create workflow", "deleted": "Workflow deleted", diff --git a/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx b/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx index c62def0a..8b31753e 100644 --- a/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx @@ -4,14 +4,14 @@ * workflow" mints a minimal trigger→action graph and opens the editor. A * pending-approvals shortcut sits in the header. */ -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; -import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil } from 'lucide-react'; +import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil, FlaskConical } from 'lucide-react'; import { Button, Card, Loading } from '../../../components/common'; -import { workflowsService, type WorkflowSummary, type WorkflowSavePayload } from '../../../services/workflows.service'; +import { workflowsService, type WorkflowSummary, type WorkflowSavePayload, type WorkflowTestResult } from '../../../services/workflows.service'; const NEW_WORKFLOW: WorkflowSavePayload = { name: 'New workflow', @@ -43,6 +43,18 @@ export const WorkflowsListPage: React.FC = () => { onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.createFailed', 'Could not create workflow') as string)), }); + const [testTarget, setTestTarget] = useState(null); + const [testEntityId, setTestEntityId] = useState(''); + const [testResult, setTestResult] = useState(null); + const testMutation = useMutation({ + mutationFn: () => workflowsService.testRun(testTarget!.id, { + entityId: testEntityId ? Number(testEntityId) : null, + dryRun: true, + }), + onSuccess: (res) => setTestResult(res), + onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.test.failed', 'Test run failed') as string)), + }); + const toggleMutation = useMutation({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => workflowsService.setEnabled(id, enabled), onSuccess: () => qc.invalidateQueries({ queryKey: ['workflows'] }), @@ -112,6 +124,9 @@ export const WorkflowsListPage: React.FC = () => { > {isEnabled(w) ? t('workflows.enabled', 'Enabled') : t('workflows.disabled', 'Disabled')} + @@ -130,6 +145,46 @@ export const WorkflowsListPage: React.FC = () => { )} + + {testTarget && ( +
setTestTarget(null)}> +
e.stopPropagation()}> +
+

{t('workflows.test.title', 'Test run')} — {testTarget.name}

+ +
+

+ {t('workflows.test.hint', 'Dry run: walks the whole flow now (waits skipped, gates auto-confirmed) with side effects mocked — no real emails. Optionally give an entity id (e.g. an invoice) so conditions can read it.')} +

+ setTestEntityId(e.target.value)} + placeholder={t('workflows.test.entityId', 'Entity id (optional, e.g. invoice id)') as string} + className="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 text-sm" + /> + + {testResult && ( +
+
+ {t('workflows.test.result', 'Result')}: {testResult.status} +
+
    + {testResult.steps.map((s, i) => ( +
  1. + {i + 1}. + {s.node_type}:{s.node_key} + {s.status} + {s.result && (s.result as any).would ? → would {String((s.result as any).would)} : null} + {s.error ? {s.error} : null} +
  2. + ))} +
+
+ )} +
+
+ )} ); }; diff --git a/frontend/src/services/workflows.service.ts b/frontend/src/services/workflows.service.ts index 644eb4e5..420ff32d 100644 --- a/frontend/src/services/workflows.service.ts +++ b/frontend/src/services/workflows.service.ts @@ -89,4 +89,23 @@ export const workflowsService = { approvals: async (): Promise => (await api.get('/admin/workflows/approvals')).data, actApproval: async (id: number, action: 'confirm' | 'deny') => (await api.post(`/admin/workflows/approvals/${id}/${action}`)).data, + testRun: async ( + id: number, + body: { entityType?: string | null; entityId?: number | null; payload?: Record; dryRun?: boolean }, + ): Promise => (await api.post(`/admin/workflows/${id}/test-run`, body)).data, }; + +export interface WorkflowTestStep { + node_key: string; + node_type?: string | null; + status: string; + result?: Record | null; + error?: string | null; +} + +export interface WorkflowTestResult { + runId: number; + dryRun: boolean; + status: string; + steps: WorkflowTestStep[]; +}