2026-09-01 16:37:53 +00:00
|
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
|
using w4c_workflows.Models;
|
|
|
|
|
using w4c_workflows.Services;
|
|
|
|
|
using Xunit;
|
|
|
|
|
|
|
|
|
|
namespace w4c_workflows.Tests;
|
|
|
|
|
|
|
|
|
|
[Collection("WorkflowsPostgres")]
|
|
|
|
|
public class WorkflowSyncIntegrationTests
|
|
|
|
|
{
|
|
|
|
|
private const string ExampleYaml = """
|
|
|
|
|
name: main-task
|
|
|
|
|
mode: handler
|
|
|
|
|
language: typescript
|
|
|
|
|
trigger:
|
|
|
|
|
type: queue
|
|
|
|
|
stream: orders
|
|
|
|
|
entry: { file: index.ts, function: handler }
|
|
|
|
|
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 readonly WorkflowsPostgresFixture _fixture;
|
|
|
|
|
|
|
|
|
|
public WorkflowSyncIntegrationTests(WorkflowsPostgresFixture fixture)
|
|
|
|
|
{
|
|
|
|
|
_fixture = fixture;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private WorkflowSyncService NewSync(string tenantId, FakeWorkflowSource source)
|
|
|
|
|
{
|
|
|
|
|
var compiler = new WorkflowCompiler(new WorkflowValidator(new LanguageRegistry()));
|
|
|
|
|
return new WorkflowSyncService(
|
|
|
|
|
_fixture.CreateContext(), compiler, source, NullLogger<WorkflowSyncService>.Instance);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Sync_compiles_and_persists_workflow_with_task_tree()
|
|
|
|
|
{
|
|
|
|
|
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
|
|
|
|
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
|
|
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
var result = await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
Assert.Equal(1, result.Compiled);
|
|
|
|
|
Assert.Empty(result.Errors);
|
|
|
|
|
|
|
|
|
|
await using var db = _fixture.CreateContext();
|
|
|
|
|
var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId);
|
|
|
|
|
Assert.Equal("main-task", workflow.Name);
|
|
|
|
|
Assert.Equal("compiled", workflow.Status);
|
|
|
|
|
Assert.Equal("abc123", workflow.GitSha);
|
|
|
|
|
Assert.Equal(5, workflow.Tasks.Count);
|
|
|
|
|
|
|
|
|
|
var root = workflow.Tasks.Single(t => t.Order == 0);
|
|
|
|
|
var validate = workflow.Tasks.Single(t => t.Order == 1);
|
|
|
|
|
Assert.Equal(validate.Id, root.NextId);
|
|
|
|
|
Assert.NotNull(validate.OnErrorId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Sync_is_idempotent_across_repeat_syncs()
|
|
|
|
|
{
|
|
|
|
|
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
|
|
|
|
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
|
2026-09-03 14:44:39 +00:00
|
|
|
var sync = () => NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
await sync();
|
|
|
|
|
var second = await sync();
|
|
|
|
|
|
|
|
|
|
Assert.Equal(1, second.Compiled);
|
|
|
|
|
await using var db = _fixture.CreateContext();
|
|
|
|
|
Assert.Equal(1, await db.Workflows.CountAsync(w => w.TenantId == tenantId));
|
|
|
|
|
Assert.Equal(5, await db.Tasks.CountAsync(t => t.Workflow.TenantId == tenantId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Sync_reconciles_tasks_when_chain_changes()
|
|
|
|
|
{
|
|
|
|
|
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
|
|
|
|
var source = new FakeWorkflowSource
|
|
|
|
|
{
|
|
|
|
|
["workflows/main-task.yaml"] = """
|
|
|
|
|
name: main-task
|
|
|
|
|
mode: function
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: a
|
|
|
|
|
next: b
|
|
|
|
|
entry: { file: a.sh }
|
|
|
|
|
- id: b
|
|
|
|
|
entry: { file: b.sh }
|
|
|
|
|
""",
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
// Add a third task to the chain.
|
|
|
|
|
source["workflows/main-task.yaml"] = """
|
|
|
|
|
name: main-task
|
|
|
|
|
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
|
|
|
|
|
entry: { file: c.sh }
|
|
|
|
|
""";
|
|
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
await using var db = _fixture.CreateContext();
|
|
|
|
|
var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId);
|
|
|
|
|
Assert.Equal(4, workflow.Tasks.Count); // root + a + b + c
|
|
|
|
|
Assert.Contains(workflow.Tasks, t => t.Order == 3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Sync_removes_workflow_when_file_deleted()
|
|
|
|
|
{
|
|
|
|
|
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
|
|
|
|
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
|
|
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default);
|
2026-09-01 16:37:53 +00:00
|
|
|
source.Clear();
|
|
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
var result = await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
Assert.Equal(1, result.Removed);
|
|
|
|
|
await using var db = _fixture.CreateContext();
|
|
|
|
|
Assert.Equal(0, await db.Workflows.CountAsync(w => w.TenantId == tenantId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Sync_removing_a_task_archives_it_and_preserves_its_taskruns()
|
|
|
|
|
{
|
|
|
|
|
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
|
|
|
|
var source = new FakeWorkflowSource { ["workflows/main-task.yaml"] = ExampleYaml };
|
|
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
// Give the 'enrich' task a historical run before it is removed from the YAML.
|
|
|
|
|
Guid enrichId;
|
|
|
|
|
await using (var db = _fixture.CreateContext())
|
|
|
|
|
{
|
|
|
|
|
var workflow = await db.Workflows.Include(w => w.Tasks).SingleAsync(w => w.TenantId == tenantId);
|
|
|
|
|
var enrich = workflow.Tasks.Single(t => t.Order == 2); // 'enrich' (csharp)
|
|
|
|
|
enrichId = enrich.Id;
|
|
|
|
|
var run = new WorkflowRun
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
WorkflowId = workflow.Id,
|
|
|
|
|
TenantId = tenantId,
|
|
|
|
|
Status = RunStatus.Succeeded,
|
|
|
|
|
};
|
|
|
|
|
db.WorkflowRuns.Add(run);
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
db.TaskRuns.Add(new TaskRun
|
|
|
|
|
{
|
|
|
|
|
Id = Guid.NewGuid(),
|
|
|
|
|
RunId = run.Id,
|
|
|
|
|
TaskId = enrichId,
|
|
|
|
|
Attempt = 1,
|
|
|
|
|
Status = TaskRunStatus.Succeeded,
|
|
|
|
|
});
|
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove 'enrich' from the YAML (simulates a rename/removal of the task).
|
|
|
|
|
source["workflows/main-task.yaml"] = """
|
|
|
|
|
name: main-task
|
|
|
|
|
mode: handler
|
|
|
|
|
language: typescript
|
|
|
|
|
trigger:
|
|
|
|
|
type: queue
|
|
|
|
|
stream: orders
|
|
|
|
|
entry: { file: index.ts, function: handler }
|
|
|
|
|
tasks:
|
|
|
|
|
- id: validate
|
|
|
|
|
parent: root
|
|
|
|
|
next: persist
|
|
|
|
|
onError: compensate
|
|
|
|
|
language: python
|
|
|
|
|
entry: { file: validate.py }
|
|
|
|
|
- id: persist
|
|
|
|
|
parent: root
|
|
|
|
|
language: shell
|
|
|
|
|
entry: { file: persist.sh }
|
|
|
|
|
- id: compensate
|
|
|
|
|
parent: root
|
|
|
|
|
language: python
|
|
|
|
|
entry: { file: compensate.py }
|
|
|
|
|
""";
|
|
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
await using var after = _fixture.CreateContext();
|
|
|
|
|
// The historical TaskRun survives — sync no longer wipes it.
|
|
|
|
|
Assert.Equal(1, await after.TaskRuns.CountAsync(t => t.TaskId == enrichId));
|
|
|
|
|
// The removed task row is archived (soft-deleted), not deleted, and excluded
|
|
|
|
|
// from active-definition queries.
|
|
|
|
|
var archived = await after.Tasks.IgnoreQueryFilters().SingleAsync(t => t.Id == enrichId);
|
|
|
|
|
Assert.NotNull(archived.ArchivedAt);
|
|
|
|
|
Assert.False(await after.Tasks.AnyAsync(t => t.Id == enrichId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task Sync_reports_errors_and_skips_invalid_files()
|
|
|
|
|
{
|
|
|
|
|
var tenantId = "t" + Guid.NewGuid().ToString("N")[..12];
|
|
|
|
|
var source = new FakeWorkflowSource
|
|
|
|
|
{
|
|
|
|
|
["workflows/broken.yaml"] = "name: broken\nmode: function\nlanguage: cobol\nentry: { file: x.sh }",
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-03 14:44:39 +00:00
|
|
|
var result = await NewSync(tenantId, source).SyncAsync(tenantId, "workflows", default);
|
2026-09-01 16:37:53 +00:00
|
|
|
|
|
|
|
|
Assert.Equal(0, result.Compiled);
|
|
|
|
|
Assert.Single(result.Errors);
|
|
|
|
|
Assert.Contains("not supported", result.Errors[0].Errors.Single());
|
|
|
|
|
|
|
|
|
|
await using var db = _fixture.CreateContext();
|
|
|
|
|
Assert.Equal(0, await db.Workflows.CountAsync(w => w.TenantId == tenantId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class FakeWorkflowSource : IWorkflowSource
|
|
|
|
|
{
|
|
|
|
|
private readonly Dictionary<string, string> _files = new(StringComparer.Ordinal);
|
|
|
|
|
|
|
|
|
|
public string this[string path]
|
|
|
|
|
{
|
|
|
|
|
get => _files[path];
|
|
|
|
|
set => _files[path] = value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Clear() => _files.Clear();
|
|
|
|
|
|
|
|
|
|
public Task<IReadOnlyList<WorkflowFile>> ListAsync(CancellationToken ct)
|
|
|
|
|
=> Task.FromResult<IReadOnlyList<WorkflowFile>>(
|
|
|
|
|
_files.Keys.Select(p => new WorkflowFile(p)).OrderBy(p => p.Path, StringComparer.Ordinal).ToList());
|
|
|
|
|
|
|
|
|
|
public Task<string> ReadAsync(string path, CancellationToken ct)
|
|
|
|
|
=> Task.FromResult(_files[path]);
|
|
|
|
|
|
|
|
|
|
public WorkflowSourceState GetState() => new("abc123", false);
|
|
|
|
|
}
|
|
|
|
|
}
|