294 lines
9.8 KiB
C#
294 lines
9.8 KiB
C#
using w4c_workflows.Models;
|
|
using w4c_workflows.Services;
|
|
using Xunit;
|
|
|
|
namespace w4c_workflows.Tests;
|
|
|
|
public class WorkflowCompilerTests
|
|
{
|
|
private const string Tenant = "t1";
|
|
private const string Path = "workflows/main-task.yaml";
|
|
|
|
private static readonly string ExampleYaml = """
|
|
name: main-task
|
|
description: Root handler consuming order events
|
|
mode: handler
|
|
language: typescript
|
|
target: default
|
|
trigger:
|
|
type: queue
|
|
stream: orders
|
|
entry: { file: index.ts, function: handler }
|
|
env: { FOO: "bar" }
|
|
tasks:
|
|
- id: validate
|
|
parent: root
|
|
next: enrich
|
|
onError: compensate
|
|
language: python
|
|
entry: { file: validate.py }
|
|
- id: enrich
|
|
parent: root
|
|
next: persist
|
|
language: csharp
|
|
entry: { file: Enrich.cs }
|
|
- id: persist
|
|
parent: root
|
|
language: shell
|
|
entry: { file: persist.sh }
|
|
- id: compensate
|
|
parent: root
|
|
language: python
|
|
entry: { file: compensate.py }
|
|
""";
|
|
|
|
private static WorkflowCompiler NewCompiler()
|
|
=> new(new WorkflowValidator(new LanguageRegistry()));
|
|
|
|
private static Guid TaskId(string id) => DeterministicGuid.For($"task::{Tenant}::workflows::{Path}::{id}");
|
|
|
|
[Fact]
|
|
public void Compiles_plan_example_with_correct_flow()
|
|
{
|
|
var result = NewCompiler().Compile(ExampleYaml, Path, Tenant);
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
var wf = result.Workflow!.Workflow;
|
|
var tasks = result.Workflow!.Tasks;
|
|
|
|
Assert.Equal("main-task", wf.Name);
|
|
Assert.Equal(WorkflowMode.Handler, wf.Mode);
|
|
Assert.Equal("compiled", wf.Status);
|
|
|
|
// 1 root + 4 declared tasks.
|
|
Assert.Equal(5, tasks.Count);
|
|
|
|
var root = tasks.Single(t => t.Order == 0);
|
|
var validate = tasks.Single(t => t.Id == TaskId("validate"));
|
|
var enrich = tasks.Single(t => t.Id == TaskId("enrich"));
|
|
var persist = tasks.Single(t => t.Id == TaskId("persist"));
|
|
var compensate = tasks.Single(t => t.Id == TaskId("compensate"));
|
|
|
|
Assert.Null(root.ParentId);
|
|
Assert.Equal(validate.Id, root.NextId);
|
|
|
|
Assert.Equal(root.Id, validate.ParentId);
|
|
Assert.Equal(enrich.Id, validate.NextId);
|
|
Assert.Equal(compensate.Id, validate.OnErrorId);
|
|
|
|
Assert.Equal(enrich.NextId, persist.Id);
|
|
Assert.Null(persist.NextId); // terminal
|
|
|
|
// Compensation task is organizational (parent: root), not on the chain.
|
|
Assert.Equal(root.Id, compensate.ParentId);
|
|
Assert.Null(compensate.NextId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Compiles_are_idempotent_same_ids_every_time()
|
|
{
|
|
var compiler = NewCompiler();
|
|
var first = compiler.Compile(ExampleYaml, Path, Tenant);
|
|
var second = compiler.Compile(ExampleYaml, Path, Tenant);
|
|
|
|
Assert.Equal(first.Workflow!.Workflow.Id, second.Workflow!.Workflow.Id);
|
|
Assert.Equal(
|
|
first.Workflow!.Tasks.Select(t => t.Id).OrderBy(x => x),
|
|
second.Workflow!.Tasks.Select(t => t.Id).OrderBy(x => x));
|
|
}
|
|
|
|
[Fact]
|
|
public void Compiles_root_only_workflow()
|
|
{
|
|
var yaml = """
|
|
name: ping
|
|
mode: function
|
|
language: shell
|
|
entry: { file: ping.sh }
|
|
""";
|
|
|
|
var result = NewCompiler().Compile(yaml, "workflows/ping.yaml", Tenant);
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
Assert.Single(result.Workflow!.Tasks);
|
|
Assert.Null(result.Workflow!.Tasks[0].NextId);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("", "name is required")]
|
|
[InlineData("mode: function\nlanguage: shell\nentry: { file: a.sh }", "name is required")]
|
|
[InlineData("name: x\nlanguage: shell\nentry: { file: a.sh }", "mode is required")]
|
|
[InlineData("name: x\nmode: function\nentry: { file: a.sh }", "language is required")]
|
|
[InlineData("name: x\nmode: function\nlanguage: cobol\nentry: { file: a.sh }", "language 'cobol' is not supported")]
|
|
public void Rejects_invalid_header(string yaml, string expectedError)
|
|
{
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
Assert.False(result.Success);
|
|
Assert.Contains(result.Errors, e => e.Contains(expectedError, StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_unknown_next_reference()
|
|
{
|
|
var yaml = """
|
|
name: x
|
|
mode: function
|
|
language: shell
|
|
entry: { file: a.sh }
|
|
tasks:
|
|
- id: a
|
|
next: ghost
|
|
entry: { file: a.sh }
|
|
""";
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
Assert.Contains(result.Errors, e => e.Contains("next 'ghost' does not reference", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_duplicate_task_ids()
|
|
{
|
|
var yaml = """
|
|
name: x
|
|
mode: function
|
|
language: shell
|
|
entry: { file: a.sh }
|
|
tasks:
|
|
- id: a
|
|
entry: { file: a.sh }
|
|
- id: a
|
|
entry: { file: b.sh }
|
|
""";
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
Assert.Contains(result.Errors, e => e.Contains("'a' is duplicated", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_cycle_in_success_chain()
|
|
{
|
|
// root -> a -> b -> c -> b (b↔c cycle; b also has 2 incoming edges)
|
|
var yaml = """
|
|
name: x
|
|
mode: function
|
|
language: shell
|
|
entry: { file: a.sh }
|
|
tasks:
|
|
- id: a
|
|
next: b
|
|
entry: { file: a.sh }
|
|
- id: b
|
|
next: c
|
|
entry: { file: b.sh }
|
|
- id: c
|
|
next: b
|
|
entry: { file: c.sh }
|
|
""";
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
Assert.False(result.Success);
|
|
Assert.Contains(result.Errors, e =>
|
|
e.Contains("cycle", StringComparison.OrdinalIgnoreCase) ||
|
|
e.Contains("linear", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_handler_mode_with_non_queue_trigger()
|
|
{
|
|
var yaml = """
|
|
name: x
|
|
mode: handler
|
|
language: shell
|
|
entry: { file: a.sh }
|
|
trigger:
|
|
type: cron
|
|
cron: "0 * * * *"
|
|
""";
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
Assert.Contains(result.Errors, e => e.Contains("handler mode requires trigger.type = 'queue'", StringComparison.Ordinal));
|
|
}
|
|
|
|
[Fact]
|
|
public void Rejects_unreachable_task()
|
|
{
|
|
// a -> b, but d is disconnected (not on the chain, not a compensation target).
|
|
var yaml = """
|
|
name: x
|
|
mode: function
|
|
language: shell
|
|
entry: { file: a.sh }
|
|
tasks:
|
|
- id: a
|
|
next: b
|
|
entry: { file: a.sh }
|
|
- id: b
|
|
entry: { file: b.sh }
|
|
- id: d
|
|
parent: root
|
|
entry: { file: d.sh }
|
|
""";
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
Assert.Contains(result.Errors, e => e.Contains("unreachable", StringComparison.OrdinalIgnoreCase));
|
|
}
|
|
|
|
[Fact]
|
|
public void Resolves_server_reference_from_header_default_and_task_override()
|
|
{
|
|
// S8: a workflow-level `server` becomes the default for every task; an explicit
|
|
// task-level `server` overrides it. Absent everywhere => null (local subprocess).
|
|
var yaml = """
|
|
name: srv-flow
|
|
mode: function
|
|
language: shell
|
|
server: srv-default
|
|
entry: { file: a.sh }
|
|
tasks:
|
|
- id: a
|
|
next: b
|
|
entry: { file: a.sh }
|
|
- id: b
|
|
server: srv-optimized
|
|
next: c
|
|
entry: { file: b.sh }
|
|
- id: c
|
|
entry: { file: c.sh }
|
|
""";
|
|
|
|
var result = NewCompiler().Compile(yaml, Path, Tenant);
|
|
|
|
Assert.True(result.Success, string.Join("\n", result.Errors));
|
|
var tasks = result.Workflow!.Tasks;
|
|
|
|
// Root + 3 declared tasks.
|
|
var root = tasks.Single(t => t.Order == 0);
|
|
var a = tasks.Single(t => t.Key == "a");
|
|
var b = tasks.Single(t => t.Key == "b");
|
|
var c = tasks.Single(t => t.Key == "c");
|
|
|
|
Assert.Equal("srv-default", root.Server); // header default applies to root
|
|
Assert.Equal("srv-default", a.Server); // header default when no override
|
|
Assert.Equal("srv-optimized", b.Server); // task-level override wins
|
|
Assert.Equal("srv-default", c.Server); // header default for a chain task
|
|
|
|
// No server anywhere => null keeps the local subprocess default.
|
|
var bare = NewCompiler().Compile(ExampleYaml, Path, Tenant);
|
|
Assert.All(bare.Workflow!.Tasks, t => Assert.Null(t.Server));
|
|
}
|
|
|
|
[Fact]
|
|
public void DurationParser_parses_common_forms()
|
|
{
|
|
Assert.True(DurationParser.TryParse("45s", out var s));
|
|
Assert.Equal(TimeSpan.FromSeconds(45), s);
|
|
Assert.True(DurationParser.TryParse("30m", out var m));
|
|
Assert.Equal(TimeSpan.FromMinutes(30), m);
|
|
Assert.True(DurationParser.TryParse("2h", out var h));
|
|
Assert.Equal(TimeSpan.FromHours(2), h);
|
|
Assert.True(DurationParser.TryParse("1h30m", out var compound));
|
|
Assert.Equal(TimeSpan.FromMinutes(90), compound);
|
|
Assert.False(DurationParser.TryParse("soon", out _));
|
|
}
|
|
}
|