2026-09-01 16:37:53 +00:00
|
|
|
using w4c_workflows.Data;
|
|
|
|
|
using w4c_workflows.Models;
|
|
|
|
|
using w4c_workflows.Services;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace w4c_workflows.Tests;
|
|
|
|
|
|
|
|
|
|
/// <summary>Shared compile-and-persist helpers for lifecycle/dispatcher tests.</summary>
|
|
|
|
|
internal static class WorkflowDataHelpers
|
|
|
|
|
{
|
|
|
|
|
public const string LinearYaml = """
|
|
|
|
|
name: linear
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: root.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
parent: root
|
|
|
|
|
next: b
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
- id: b
|
|
|
|
|
parent: root
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: b.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
public const string DurableYaml = """
|
|
|
|
|
name: durable
|
|
|
|
|
mode: durable
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: root.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
parent: root
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
/// <summary>Three-task success chain where every chained task declares its own
|
|
|
|
|
/// compensation (<c>onError</c>) target, for saga reverse-compensation tests.</summary>
|
|
|
|
|
public const string SagaYaml = """
|
|
|
|
|
name: saga
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: root.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
parent: root
|
|
|
|
|
next: b
|
|
|
|
|
onError: compensate_a
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
- id: b
|
|
|
|
|
parent: root
|
|
|
|
|
next: c
|
|
|
|
|
onError: compensate_b
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: b.sh }
|
|
|
|
|
- id: c
|
|
|
|
|
parent: root
|
|
|
|
|
onError: compensate_c
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: c.sh }
|
|
|
|
|
- id: compensate_a
|
|
|
|
|
parent: root
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: compensate_a.sh }
|
|
|
|
|
- id: compensate_b
|
|
|
|
|
parent: root
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: compensate_b.sh }
|
|
|
|
|
- id: compensate_c
|
|
|
|
|
parent: root
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: compensate_c.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
2026-09-11 22:02:46 +00:00
|
|
|
/// <summary>Two-step node-mode workflow (both steps run in-process, no subprocess).</summary>
|
|
|
|
|
public const string NodeYaml = """
|
|
|
|
|
name: node-linear
|
|
|
|
|
tasks:
|
|
|
|
|
- id: seed
|
|
|
|
|
node: { type: core.set }
|
|
|
|
|
parameters:
|
|
|
|
|
mode: json
|
|
|
|
|
jsonOutput: { value: 1 }
|
|
|
|
|
next: shape
|
|
|
|
|
- id: shape
|
|
|
|
|
node: { type: core.set }
|
|
|
|
|
parameters:
|
|
|
|
|
mode: manual
|
|
|
|
|
keepOnlySet: true
|
|
|
|
|
fields:
|
|
|
|
|
- name: copied
|
|
|
|
|
value: '={{ $json.value }}'
|
|
|
|
|
""";
|
|
|
|
|
|
|
|
|
|
/// <summary>Node-mode workflow whose step has no installed executor (fails the run).</summary>
|
|
|
|
|
public const string NodeNoExecutorYaml = """
|
|
|
|
|
name: node-no-executor
|
|
|
|
|
tasks:
|
|
|
|
|
- id: fetch
|
|
|
|
|
node: { type: core.httpRequest }
|
|
|
|
|
parameters:
|
|
|
|
|
url: https://example.com
|
|
|
|
|
""";
|
|
|
|
|
|
2026-09-01 16:37:53 +00:00
|
|
|
public static Workflow CompileAndSave(WorkflowsDbContext db, string tenantId, string yaml, string path = "workflows/linear.yaml")
|
|
|
|
|
{
|
|
|
|
|
var compiler = new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry()));
|
|
|
|
|
var result = compiler.Compile(yaml, path, tenantId);
|
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
|
|
|
|
|
|
|
|
var workflow = result.Workflow!.Workflow;
|
|
|
|
|
workflow.Tasks = result.Workflow.Tasks.ToList();
|
2026-09-11 22:02:46 +00:00
|
|
|
workflow.TaskEdges = result.Workflow.Edges.ToList();
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
db.Workflows.Add(workflow);
|
|
|
|
|
db.SaveChanges();
|
|
|
|
|
return workflow;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static WorkflowTask TaskByKey(Workflow workflow, string key)
|
|
|
|
|
=> workflow.Tasks.Single(t => t.Key == key);
|
|
|
|
|
|
|
|
|
|
public static WorkflowRun AddPendingRun(WorkflowsDbContext db, Workflow workflow, string? input = null, Guid? startTaskId = null)
|
|
|
|
|
{
|
|
|
|
|
var run = new WorkflowRun
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
WorkflowId = workflow.Id,
|
|
|
|
|
TenantId = workflow.TenantId,
|
|
|
|
|
Status = RunStatus.Pending,
|
|
|
|
|
TriggerJson = "{\"type\":\"event\"}",
|
|
|
|
|
InputJson = input,
|
|
|
|
|
CorrelationId = "test-correlation",
|
|
|
|
|
StartTaskId = startTaskId,
|
|
|
|
|
};
|
|
|
|
|
db.WorkflowRuns.Add(run);
|
|
|
|
|
db.SaveChanges();
|
|
|
|
|
return run;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Adds a run in a terminal/in-flight status with an explicit start time.</summary>
|
|
|
|
|
public static WorkflowRun AddRun(WorkflowsDbContext db, Workflow workflow, string status, DateTime? startedAt = null, DateTime? finishedAt = null, string? error = null)
|
|
|
|
|
{
|
|
|
|
|
var run = new WorkflowRun
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
WorkflowId = workflow.Id,
|
|
|
|
|
TenantId = workflow.TenantId,
|
|
|
|
|
Status = status,
|
|
|
|
|
TriggerJson = "{\"type\":\"event\"}",
|
|
|
|
|
CorrelationId = $"corr-{Guid.NewGuid():N}",
|
|
|
|
|
StartedAt = startedAt,
|
|
|
|
|
FinishedAt = finishedAt,
|
|
|
|
|
Error = error,
|
|
|
|
|
};
|
|
|
|
|
db.WorkflowRuns.Add(run);
|
|
|
|
|
db.SaveChanges();
|
|
|
|
|
return run;
|
|
|
|
|
}
|
|
|
|
|
}
|