w4c-workflows-api/w4c-workflows-api.Tests/WorkflowCompilerTests.cs

294 lines
9.8 KiB
C#
Raw Normal View History

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()));
2026-09-03 14:44:39 +00:00
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 _));
}
}